Skip to content

Instantly share code, notes, and snippets.

@anonymousik
Last active April 28, 2026 23:00
Show Gist options
  • Select an option

  • Save anonymousik/3ac1045b46a154b8665705f2fe4e2b23 to your computer and use it in GitHub Desktop.

Select an option

Save anonymousik/3ac1045b46a154b8665705f2fe4e2b23 to your computer and use it in GitHub Desktop.
TVRemote Project Manager · v2.0 0 (Termux / Android aarch64 · localhost web dashboard)

SecFERRO DevOps Center · Project Manager v2.0

Bezzależnościowy, jednoplikowy mikrokontroler (Web Dashboard) z wbudowanym systemem Server-Sent Events (SSE). Zaprojektowany do wstrzykiwania kodu i zarządzania cyklem życia projektów z poziomu natywnego środowiska Android/Termux.

SecFERRO Project Manager eliminuje konieczność ręcznego operowania skryptami i śledzenia logów w konsoli. Zastępuje to responsywnym panelem GUI w przeglądarce telefonu.

✨ Kluczowe Funkcjonalności

  • 🚀 Zero Dependencies Philosophy: Cały serwer HTTP, routing, WebSockets (SSE) oraz front-end (SPA) mieszczą się w jednym pliku project_manager.py (ok. 1000 linii kodu). Opiera się wyłącznie na bibliotece standardowej Pythona (Python 3.8+).
  • 📡 Live Terminal (Server-Sent Events): Bezpośredni, asynchroniczny zrzut stdout i stderr ze skryptów Pythona prosto do okna przeglądarki. Zero opóźnień, zero przeładowań strony.
  • 📦 Zautomatyzowany Scaffolding: Domyślnie zintegrowany z 8-fazowym systemem iniekcji architektury "TVRemote" (Clean Architecture, Jetpack Compose, Room, Hilt).
  • 🛡️ SecFerro State Management: Atomowe blokady (threading.Lock) chronią plik .pm_state.json, gwarantując spójność stanu nawet przy siłowym uiciu procesu (SIGKILL).

⚡ Błyskawiczna Instalacja (Termux One-Liner)

Uruchom poniższe polecenie w swoim terminalu Termux na Androidzie. Skrypt zaktualizuje pakiety, pobierze Menedżera i natychmiast wystartuje serwer.

pkg update -y && pkg install python curl -y && curl -sLO [https://gist.githubusercontent.com/anonymousik/3ac1045b46a154b8665705f2fe4e2b23/raw/project_manager.py](https://gist.githubusercontent.com/anonymousik/3ac1045b46a154b8665705f2fe4e2b23/raw/project_manager.py) && python project_manager.py

👉 Po uruchomieniu, otwórz przeglądarkę na telefonie i wejdź pod adres: http://127.0.0.1:8080

🧩 Wymagania Systemowe

  • OS: Android (Termux) / Linux / macOS
  • Środowisko: Python 3.8 lub nowszy
  • Przeglądarka: Dowolna wspierająca ES6 i SSE (np. Chrome, Firefox Mobile, Brave) SecFERRO Division • Inżynieria Niezawodności • 2026

SecFERRO DevOps Center · Project Manager v2.0

v200 - first public version !

Standardy Wkładu i Kooperacji (CONTRIBUTING)

Dziękujemy za zainteresowanie projektem SecFERRO TVRemote Project Manager. Jako projekt stawiający na elitarną inżynierię oprogramowania, egzekwujemy rygorystyczne normy dotyczące wkładu kodu do głównej gałęzi (Main Branch).

1. Doktryna "Zero Dependencies"

Dla utrzymania pełnej kompatybilności przenośnej (szczególnie dla emulatorów środowiska Termux na niestandardowych architekturach Androida), bezwzględnie obowiązuje zasada odrzucania zewnętrznych zależności.

  • Zakaz używania pip: Wszelkie pull requesty (PR) zawierające pliki requirements.txt lub odwołujące się do bibliotek trzecich (np. requests, Flask, Jinja2, pydantic) będą odrzucane z automatu.
  • Akceptowane obejścia: Musisz używać odpowiedników z stdlib Pythona (np. urllib.request zamiast requests, http.server zamiast Flask).

2. Procedura dodawania nowych Faz projektu

Jeżeli system Scaffolding wymaga rozbudowy o kolejną fazę generowania kodu (np. Faza 9):

  1. Utwórz samodzielny skrypt iniekcyjny np. update_9z6.py.
  2. Otwórz project_manager.py i rozszerz tablicę obiektową PHASES.
  3. Dodaj referencje do struktury w formacie:
{
    "id": "9", "script": "update_9z6.py",
    "title": "Faza 9 — Tytuł Twojej Modyfikacji",
    "desc": "Krótki opis, jak ten skrypt wpłynie na generowany kod",
    "files": 4, # Deklarowana liczba nowo dodanych plików
    "tag": "advanced", # Jeden ze wspieranych stylów (ui, logic, network)
    "deps": ["8"], # Konieczność wykonania poprawnie poprzedniej fazy
}

3. Wymagania dotyczące Frontend'u (UI/UX)

Jeśli Twoja propozycja modyfikuje blok kodu _build_dashboard_html():

  • Trzymaj się zadeklarowanych na samej górze zmiennych CSS (Custom Properties np. --surface, --purple).
  • Nie dodawaj bibliotek ikon (FontAwesome) - używaj natywnych znaków EMOJI lub Unicode, minimalizując rozmiar i strzały HTTP.
  • Zwracaj szczególną uwagę na skalowanie urządzeń mobilnych (@media(max-width:700px)).

4. Wytyczne Commitowania

Używamy standardu Conventional Commits:

  • feat: (Nowa funkcja dodana do skryptu)
  • fix: (Naprawa luki / błędu / usterki renderowania GUI)
  • docs: (Rozbudowa niniejszego systemu dokumentacji)
  • refactor: (Przepisanie logiki Pythona np. dla poprawy bezpieczeństwa wejścia)

Architektura i Dokumentacja Wewnętrzna (Internals)

Skrypt project_manager.py jest niezwykłym przykładem tzw. Monolithic Micro-Application. Projekt ten kompresuje cały stos technologiczny (Backend, Database, Real-Time Comms, Frontend) do jednego pliku.

1. Architektura Backendowa (Python Core)

  • Warstwa HTTP (PMHandler): System omija ciężkie frameworki (Django/FastAPI) dziedzicząc z wbudowanego BaseHTTPRequestHandler. Rozwiązuje to problem zależności w kontenerach Termuxa. Routing oparty jest o klasyczny blok if/elif.
  • Warstwa Stanu (ProjectState): Cały stan (wersje, zadania, notatki) utrzymywany jest w pamięci operacyjnej jako słownik, chroniony przed wyścigami (Race Conditions) przez threading.Lock(). Cyklicznie serializowany do dyskowego pliku .pm_state.json.
  • Warstwa Wykonawcza (ScriptRunner): Służy jako orkiestrator. Nowe zadania spawnowane są przez wbudowany moduł subprocess.Popen i opakowywane w wątki poboczne (threading.Thread).

2. Server-Sent Events (Telemetria Real-Time)

Rozwiązanie problemu Live-Terminala bez używania WebSockets i bibliotek zewnętrznych:

  1. Podczas startu zadania, tworzona jest kolejka blokująca queue.Queue().
  2. Wątek w tle nasłuchuje STDOUT/STDERR podprocesu i wrzuca do kolejki surowe linie tekstu.
  3. API w punkcie /api/stream/{job_id} odpowiada nagłówkami text/event-stream i pętlą while True zaczytuje kolejkę, wypychając ją przez aktywne gniazdo HTTP. Zabezpieczono to przed wygasaniem pakietami Keep-Alive (: ping\n\n).

3. Frontend (SPA Injection)

Cały UI został osadzony wewnątrz Pythona jako zoptymalizowany string (_build_dashboard_html()).

  • Styling: Brak bibliotek takich jak Tailwind czy Bootstrap. CSS opiera się o natywne zmienne (Custom Properties) tworząc zunifikowany, ciemny motyw.
  • Logika: Lekki JS (~200 linii) steruje DOMem, wywoływaniem API, i strumieniowaniem logów obiektu EventSource.

4. Algorytm Składania Kodu (The Scaffolding Accumulator)

Głównym zadaniem aplikacji jest wsparcie iniekcji kodu z plików update_Nz6.py. Moduł ProjectInspector dynamicznie bada plik update_0.py w czasie rzeczywistym używając refleksji (moduł importlib.util Pythona), aby policzyć rozmiar tablic wygenerowanego kodu źródłowego.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
╔══════════════════════════════════════════════════════════════════════════╗
║ TVRemote Project Manager · v2.0.0 ║
║ Termux / Android aarch64 · localhost web dashboard ║
╠══════════════════════════════════════════════════════════════════════════╣
║ python project_manager.py → start (domyślnie port 8080) ║
║ python project_manager.py --port 9090 → custom port ║
║ python project_manager.py --host 0.0.0.0 → dostęp z LAN ║
╚══════════════════════════════════════════════════════════════════════════╝
Wymagania Termux: python (pkg install python)
Bez zewnętrznych zależności — tylko stdlib Python 3.8+
"""
# ─────────────────────────────────────────────────────────────────────────────
# STDLIB IMPORTS
# ─────────────────────────────────────────────────────────────────────────────
import sys, os, json, time, uuid, threading, subprocess, shutil, signal
import importlib.util, hashlib, platform, socket, re, queue
from pathlib import Path
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs, unquote
from typing import Dict, List, Optional, Any
# ─────────────────────────────────────────────────────────────────────────────
# KONFIGURACJA
# ─────────────────────────────────────────────────────────────────────────────
DEFAULT_PORT = 8080
DEFAULT_HOST = "127.0.0.1"
BASE_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = BASE_DIR / "TVRemoteApp"
LOG_DIR = BASE_DIR / ".pm_logs"
STATE_FILE = BASE_DIR / ".pm_state.json"
CHANGELOG_FILE= BASE_DIR / "CHANGELOG.md"
LOG_DIR.mkdir(exist_ok=True)
VERSION = "2.0.0"
BRAND = "SecFERRO DevOps Center"
# ─────────────────────────────────────────────────────────────────────────────
# DEFINICJA FAZ
# ─────────────────────────────────────────────────────────────────────────────
PHASES: List[Dict[str, Any]] = [
{
"id": "1", "script": "update_1z6.py",
"title": "Faza 1 — Setup",
"desc": "Hilt · Nawigacja · Temat ciemny (neon purple/blue)",
"files": 21, "tag": "foundation",
"deps": [],
},
{
"id": "2", "script": "update_2z6.py",
"title": "Faza 2 — Core UI",
"desc": "D-Pad · Touchpad · Media Controls · AppLauncher",
"files": 13, "tag": "ui",
"deps": ["1"],
},
{
"id": "3", "script": "update_3z6.py",
"title": "Faza 3 — Logika",
"desc": "Repository · UseCase · Haptic · WakeLock · Theater",
"files": 14, "tag": "logic",
"deps": ["2"],
},
{
"id": "4", "script": "update_4z6.py",
"title": "Faza 4 — Device Discovery",
"desc": "NsdManager · Room DB · DeviceRepository · DeviceListScreen",
"files": 19, "tag": "network",
"deps": ["3"],
},
{
"id": "5", "script": "update_5z6.py",
"title": "Faza 5 — Settings",
"desc": "DataStore · AppSettings · SettingsScreen · Premium Billing",
"files": 16, "tag": "settings",
"deps": ["4"],
},
{
"id": "6", "script": "update_6z6.py",
"title": "Faza 6 — Finalizacja",
"desc": "ProGuard · NetworkSecurity · README · Integracja",
"files": 12, "tag": "release",
"deps": ["5"],
},
{
"id": "7", "script": "update_7.py",
"title": "Faza 7 — ATV Protocol",
"desc": "TLS Pairing · ForegroundService · MediaNotification",
"files": 18, "tag": "protocol",
"deps": ["6"],
},
{
"id": "8", "script": "update_8.py",
"title": "Faza 8 — Canvas & Voice",
"desc": "Canvas D-Pad · Klawiatura pełna · Wyszukiwanie głosowe",
"files": 0, "tag": "advanced",
"deps": ["7"],
},
]
# ─────────────────────────────────────────────────────────────────────────────
# MENEDŻER STANU PROJEKTU
# ─────────────────────────────────────────────────────────────────────────────
class ProjectState:
"""Trwały stan projektu — zapisywany do .pm_state.json"""
def __init__(self):
self._lock = threading.Lock()
self._data: Dict[str, Any] = {
"applied_phases" : [],
"version" : "1.0.0",
"build_number" : 0,
"last_build" : None,
"jobs" : {},
"notes" : "",
"custom_scripts" : [],
}
self._load()
def _load(self):
if STATE_FILE.exists():
try:
with open(STATE_FILE) as f:
saved = json.load(f)
self._data.update(saved)
except Exception:
pass
def save(self):
with self._lock:
with open(STATE_FILE, "w") as f:
# Nie serializuj dużych obiektów jobs (tylko metadane)
data_copy = dict(self._data)
data_copy["jobs"] = {
k: {m: v for m, v in job.items() if m != "output_lines"}
for k, job in self._data["jobs"].items()
}
json.dump(data_copy, f, indent=2, default=str)
def get(self, key: str, default=None):
return self._data.get(key, default)
def set(self, key: str, value: Any):
with self._lock:
self._data[key] = value
self.save()
def mark_phase_applied(self, phase_id: str):
with self._lock:
applied = self._data.setdefault("applied_phases", [])
if phase_id not in applied:
applied.append(phase_id)
self._data["last_build"] = datetime.now(timezone.utc).isoformat()
self.save()
def phase_applied(self, phase_id: str) -> bool:
return phase_id in self._data.get("applied_phases", [])
def add_job(self, job: Dict[str, Any]):
with self._lock:
self._data["jobs"][job["id"]] = job
def update_job(self, job_id: str, updates: Dict[str, Any]):
with self._lock:
if job_id in self._data["jobs"]:
self._data["jobs"][job_id].update(updates)
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
return self._data["jobs"].get(job_id)
def get_jobs_list(self) -> List[Dict[str, Any]]:
jobs = list(self._data["jobs"].values())
jobs.sort(key=lambda j: j.get("started_at", ""), reverse=True)
return jobs[:50]
# ─────────────────────────────────────────────────────────────────────────────
# RUNNER — wykonywanie skryptów z live output
# ─────────────────────────────────────────────────────────────────────────────
class ScriptRunner:
"""Uruchamia skrypty Python jako podprocesy, strumieniuje output przez SSE."""
def __init__(self, state: ProjectState):
self.state = state
self._streams : Dict[str, queue.Queue] = {}
self._lock = threading.Lock()
def run(self, script_path: str, args: List[str], phase_id: str = "",
label: str = "") -> str:
"""Uruchom skrypt asynchronicznie. Zwraca job_id."""
job_id = str(uuid.uuid4())[:8]
q: queue.Queue = queue.Queue(maxsize=2000)
job = {
"id" : job_id,
"script" : Path(script_path).name,
"phase_id" : phase_id,
"label" : label or Path(script_path).name,
"args" : args,
"status" : "running",
"started_at" : datetime.now(timezone.utc).isoformat(),
"finished_at" : None,
"exit_code" : None,
"output_lines": [],
}
self.state.add_job(job)
with self._lock:
self._streams[job_id] = q
threading.Thread(
target=self._worker,
args=(job_id, script_path, args, phase_id, q),
daemon=True,
).start()
return job_id
def get_stream(self, job_id: str) -> Optional[queue.Queue]:
return self._streams.get(job_id)
def _worker(self, job_id: str, script_path: str, args: List[str],
phase_id: str, q: queue.Queue):
log_file = LOG_DIR / f"{job_id}.log"
lines = []
def emit(line: str):
lines.append(line)
q.put(("line", line))
with open(log_file, "a", encoding="utf-8") as lf:
lf.write(line + "\n")
try:
cmd = [sys.executable, script_path] + args
emit(f"[PM] ▶ {' '.join(cmd)}")
emit(f"[PM] ⏱ {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
emit("")
proc = subprocess.Popen(
cmd,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
cwd = str(BASE_DIR),
bufsize = 1,
)
for raw_line in proc.stdout:
emit(raw_line.rstrip())
proc.wait()
exit_code = proc.returncode
emit("")
emit(f"[PM] ✅ Zakończono · exit={exit_code} · "
f"{datetime.now().strftime('%H:%M:%S')}")
if exit_code == 0 and phase_id and "--finish" in args:
self.state.mark_phase_applied(phase_id)
self.state.update_job(job_id, {
"status" : "success" if exit_code == 0 else "error",
"exit_code" : exit_code,
"finished_at" : datetime.now(timezone.utc).isoformat(),
"output_lines": lines,
})
except Exception as e:
emit(f"[PM] ❌ Wyjątek: {e}")
self.state.update_job(job_id, {
"status" : "error",
"exit_code" : -1,
"finished_at" : datetime.now(timezone.utc).isoformat(),
"output_lines": lines,
})
finally:
q.put(("done", ""))
# Poczekaj chwilę zanim usuniemy stream (SSE może jeszcze czytać)
def cleanup():
time.sleep(30)
with self._lock:
self._streams.pop(job_id, None)
threading.Thread(target=cleanup, daemon=True).start()
# ─────────────────────────────────────────────────────────────────────────────
# INSPEKTOR PROJEKTU
# ─────────────────────────────────────────────────────────────────────────────
class ProjectInspector:
"""Analizuje aktualny stan projektu na dysku."""
@staticmethod
def get_project_stats() -> Dict[str, Any]:
stats = {
"root_exists" : PROJECT_ROOT.exists(),
"total_files" : 0,
"total_size_kb" : 0,
"kt_files" : 0,
"xml_files" : 0,
"gradle_files" : 0,
"update0_files" : 0,
}
if PROJECT_ROOT.exists():
for f in PROJECT_ROOT.rglob("*"):
if f.is_file():
stats["total_files"] += 1
stats["total_size_kb"] += f.stat().st_size // 1024
ext = f.suffix.lower()
if ext == ".kt": stats["kt_files"] += 1
elif ext == ".xml": stats["xml_files"] += 1
elif "gradle" in f.name: stats["gradle_files"] += 1
# Ile plików jest w update_0.py
u0 = BASE_DIR / "update_0.py"
if u0.exists():
try:
spec = importlib.util.spec_from_file_location("update_0", u0)
if spec and spec.loader:
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
files_dict = getattr(mod, "FILES", {})
stats["update0_files"] = len(files_dict)
except Exception:
pass
return stats
@staticmethod
def get_script_inventory() -> List[Dict[str, Any]]:
result = []
for ph in PHASES:
script = BASE_DIR / ph["script"]
result.append({
"phase_id" : ph["id"],
"script" : ph["script"],
"exists" : script.exists(),
"size_kb" : (script.stat().st_size // 1024) if script.exists() else 0,
"modified" : (datetime.fromtimestamp(script.stat().st_mtime)
.strftime("%Y-%m-%d %H:%M") if script.exists() else None),
})
return result
@staticmethod
def get_update0_file_list() -> List[str]:
u0 = BASE_DIR / "update_0.py"
if not u0.exists():
return []
try:
spec = importlib.util.spec_from_file_location("update_0", u0)
if spec and spec.loader:
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return list(getattr(mod, "FILES", {}).keys())
except Exception:
pass
return []
@staticmethod
def get_system_info() -> Dict[str, Any]:
return {
"platform" : platform.platform(),
"python" : sys.version.split()[0],
"cpu" : platform.machine(),
"node" : platform.node(),
"cwd" : str(BASE_DIR),
"free_mb" : shutil.disk_usage(BASE_DIR).free // (1024 * 1024),
"total_mb" : shutil.disk_usage(BASE_DIR).total // (1024 * 1024),
}
# ─────────────────────────────────────────────────────────────────────────────
# CHANGELOG MANAGER
# ─────────────────────────────────────────────────────────────────────────────
class ChangelogManager:
@staticmethod
def read() -> str:
if CHANGELOG_FILE.exists():
return CHANGELOG_FILE.read_text(encoding="utf-8")
return "# Changelog\n\n## [Unreleased]\n\n"
@staticmethod
def write(content: str):
CHANGELOG_FILE.write_text(content, encoding="utf-8")
@staticmethod
def add_entry(version: str, entries: List[str]):
existing = ChangelogManager.read()
date = datetime.now().strftime("%Y-%m-%d")
block = f"## [{version}] — {date}\n"
for e in entries:
block += f"- {e}\n"
block += "\n"
updated = existing.replace("## [Unreleased]\n\n", f"## [Unreleased]\n\n{block}")
ChangelogManager.write(updated)
# ─────────────────────────────────────────────────────────────────────────────
# HTTP HANDLER
# ─────────────────────────────────────────────────────────────────────────────
class PMHandler(BaseHTTPRequestHandler):
state : ProjectState
runner : ScriptRunner
inspector : ProjectInspector
def log_message(self, fmt, *args):
pass # Wycisz domyślne logi HTTP
# ── Routing ──────────────────────────────────────────────────────────────
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
qs = parse_qs(parsed.query)
routes = {
"/" : self._serve_dashboard,
"/api/status" : self._api_status,
"/api/phases" : self._api_phases,
"/api/jobs" : self._api_jobs,
"/api/files" : self._api_files,
"/api/sysinfo" : self._api_sysinfo,
"/api/changelog" : self._api_changelog_get,
"/api/logs" : self._api_logs_list,
}
if path.startswith("/api/stream/"):
job_id = path.split("/")[-1]
self._api_stream(job_id)
return
if path.startswith("/api/log/"):
job_id = path.split("/")[-1]
self._api_log_content(job_id)
return
handler = routes.get(path)
if handler:
handler()
else:
self._send_json({"error": "Not found"}, 404)
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
body = self._read_body()
if path.startswith("/api/run/"):
phase_id = path.split("/")[-1]
self._api_run_phase(phase_id, body)
elif path == "/api/run-script":
self._api_run_custom(body)
elif path == "/api/changelog":
self._api_changelog_post(body)
elif path == "/api/version":
self._api_version_bump(body)
elif path == "/api/reset-phase":
self._api_reset_phase(body)
elif path == "/api/clean":
self._api_clean(body)
elif path == "/api/notes":
self._api_notes(body)
else:
self._send_json({"error": "Not found"}, 404)
def do_OPTIONS(self):
self.send_response(200)
self._cors_headers()
self.end_headers()
# ── API Handlers ─────────────────────────────────────────────────────────
def _api_status(self):
stats = ProjectInspector.get_project_stats()
applied = self.state.get("applied_phases", [])
jobs = self.state.get_jobs_list()
running = [j for j in jobs if j["status"] == "running"]
self._send_json({
"version" : self.state.get("version", "1.0.0"),
"build_number" : self.state.get("build_number", 0),
"last_build" : self.state.get("last_build"),
"applied_phases" : applied,
"phases_total" : len(PHASES),
"phases_done" : len(applied),
"project_stats" : stats,
"running_jobs" : len(running),
"notes" : self.state.get("notes", ""),
"pm_version" : VERSION,
"timestamp" : datetime.now(timezone.utc).isoformat(),
})
def _api_phases(self):
inventory = {s["phase_id"]: s for s in ProjectInspector.get_script_inventory()}
result = []
for ph in PHASES:
inv = inventory.get(ph["id"], {})
deps_ok = all(self.state.phase_applied(d) for d in ph["deps"])
result.append({
**ph,
"applied" : self.state.phase_applied(ph["id"]),
"script_exists": inv.get("exists", False),
"script_size" : inv.get("size_kb", 0),
"script_mtime" : inv.get("modified"),
"deps_ok" : deps_ok,
"can_run" : inv.get("exists", False) and deps_ok,
})
self._send_json(result)
def _api_run_phase(self, phase_id: str, body: Dict):
ph = next((p for p in PHASES if p["id"] == phase_id), None)
if not ph:
self._send_json({"error": f"Nieznana faza: {phase_id}"}, 404)
return
script = BASE_DIR / ph["script"]
if not script.exists():
self._send_json({"error": f"Brak skryptu: {ph['script']}"}, 400)
return
dry_run = body.get("dry_run", False)
args = [] if dry_run else ["--finish"]
label = f"{'[DRY-RUN] ' if dry_run else ''}{ph['title']}"
job_id = self.runner.run(
script_path = str(script),
args = args,
phase_id = ph["id"] if not dry_run else "",
label = label,
)
self._send_json({"job_id": job_id, "label": label, "dry_run": dry_run})
def _api_run_custom(self, body: Dict):
script_name = body.get("script", "")
args = body.get("args", [])
script = BASE_DIR / script_name
if not script.exists():
self._send_json({"error": f"Brak skryptu: {script_name}"}, 400)
return
# Bezpieczeństwo: tylko pliki .py w BASE_DIR
try:
script.resolve().relative_to(BASE_DIR)
except ValueError:
self._send_json({"error": "Niedozwolona ścieżka"}, 403)
return
job_id = self.runner.run(str(script), args, label=script_name)
self._send_json({"job_id": job_id})
def _api_stream(self, job_id: str):
"""Server-Sent Events — strumieniowanie output w czasie rzeczywistym."""
q = self.runner.get_stream(job_id)
# Jeśli strumień już zamknięty — wyślij logi z historii
if q is None:
job = self.state.get_job(job_id)
if not job:
self._send_json({"error": "Job nie znaleziony"}, 404)
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self._cors_headers()
self.end_headers()
def write_event(event_type: str, data: str):
try:
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
self.wfile.write(payload.encode("utf-8"))
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
return False
return True
# Wyślij historię dla zakończonych jobów
if q is None:
job = self.state.get_job(job_id)
for line in job.get("output_lines", []):
if not write_event("line", line):
return
write_event("done", job.get("status", "unknown"))
return
# Strumieniuj na żywo
while True:
try:
msg_type, data = q.get(timeout=25)
if msg_type == "done":
job = self.state.get_job(job_id)
write_event("done", job.get("status", "unknown") if job else "done")
break
else:
if not write_event("line", data):
break
except queue.Empty:
# Keepalive ping (zapobiega timeout)
try:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
def _api_jobs(self):
self._send_json(self.state.get_jobs_list())
def _api_log_content(self, job_id: str):
log = LOG_DIR / f"{job_id}.log"
if not log.exists():
job = self.state.get_job(job_id)
if job:
self._send_json({"content": "\n".join(job.get("output_lines", []))})
else:
self._send_json({"error": "Log nie znaleziony"}, 404)
return
self._send_json({"content": log.read_text(encoding="utf-8", errors="replace")})
def _api_logs_list(self):
logs = []
for f in sorted(LOG_DIR.glob("*.log"), key=lambda x: x.stat().st_mtime, reverse=True)[:30]:
logs.append({
"name" : f.stem,
"size_kb" : f.stat().st_size // 1024,
"mtime" : datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
})
self._send_json(logs)
def _api_files(self):
files = ProjectInspector.get_update0_file_list()
self._send_json({
"files" : files,
"count" : len(files),
"source" : "update_0.py",
})
def _api_sysinfo(self):
self._send_json(ProjectInspector.get_system_info())
def _api_changelog_get(self):
self._send_json({"content": ChangelogManager.read()})
def _api_changelog_post(self, body: Dict):
content = body.get("content", "")
if content:
ChangelogManager.write(content)
self._send_json({"ok": True})
else:
self._send_json({"error": "Pusta zawartość"}, 400)
def _api_version_bump(self, body: Dict):
bump = body.get("type", "patch")
ver = self.state.get("version", "1.0.0")
parts = [int(x) for x in ver.split(".")]
if bump == "major": parts[0] += 1; parts[1] = 0; parts[2] = 0
elif bump == "minor": parts[1] += 1; parts[2] = 0
else: parts[2] += 1
new_ver = ".".join(map(str, parts))
build = self.state.get("build_number", 0) + 1
self.state.set("version", new_ver)
self.state.set("build_number", build)
entries = body.get("entries", [f"Bump version to {new_ver}"])
ChangelogManager.add_entry(new_ver, entries)
self._send_json({"version": new_ver, "build_number": build})
def _api_reset_phase(self, body: Dict):
phase_id = body.get("phase_id", "")
applied = self.state.get("applied_phases", [])
if phase_id in applied:
applied.remove(phase_id)
self.state.set("applied_phases", applied)
self._send_json({"ok": True, "applied_phases": applied})
def _api_clean(self, body: Dict):
target = body.get("target", "")
if target == "project" and PROJECT_ROOT.exists():
shutil.rmtree(PROJECT_ROOT)
self._send_json({"ok": True, "message": f"{PROJECT_ROOT} usunięty"})
elif target == "logs":
for f in LOG_DIR.glob("*.log"):
f.unlink()
self._send_json({"ok": True, "message": "Logi wyczyszczone"})
elif target == "state":
if STATE_FILE.exists():
STATE_FILE.unlink()
self._send_json({"ok": True, "message": "Stan projektu zresetowany"})
else:
self._send_json({"error": f"Nieznany cel: {target}"}, 400)
def _api_notes(self, body: Dict):
self.state.set("notes", body.get("notes", ""))
self._send_json({"ok": True})
# ── Dashboard HTML ────────────────────────────────────────────────────────
def _serve_dashboard(self):
html = _build_dashboard_html()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html.encode("utf-8"))))
self.end_headers()
self.wfile.write(html.encode("utf-8"))
# ── Helpers ───────────────────────────────────────────────────────────────
def _send_json(self, data: Any, status: int = 200):
body = json.dumps(data, default=str, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self._cors_headers()
self.end_headers()
self.wfile.write(body)
def _cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def _read_body(self) -> Dict:
length = int(self.headers.get("Content-Length", 0))
if length == 0:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except Exception:
return {}
# ─────────────────────────────────────────────────────────────────────────────
# DASHBOARD HTML (jednolikowy SPA — wbudowany w skrypt)
# ─────────────────────────────────────────────────────────────────────────────
def _build_dashboard_html() -> str:
return r"""<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>SecFERRO DevOps Center</title>
<style>
:root {
--bg: #08080f;
--surface: #0f0f1a;
--surface2: #161625;
--border: #1e1e35;
--purple: #7c3aed;
--blue: #2563eb;
--cyan: #06b6d4;
--green: #10b981;
--red: #ef4444;
--yellow: #f59e0b;
--text: #e2e8f0;
--muted: #64748b;
--dim: #334155;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'JetBrains Mono',monospace,sans-serif;
font-size:13px;overflow-x:hidden}
/* Layout */
.app{display:grid;grid-template-columns:220px 1fr;grid-template-rows:56px 1fr;
height:100vh;gap:0}
.topbar{grid-column:1/-1;background:var(--surface);border-bottom:1px solid var(--border);
display:flex;align-items:center;padding:0 20px;gap:16px;z-index:10}
.sidebar{background:var(--surface);border-right:1px solid var(--border);
overflow-y:auto;padding:12px 8px}
.main{overflow-y:auto;padding:20px}
/* Brand */
.brand{font-size:14px;font-weight:700;color:var(--cyan);letter-spacing:1px}
.brand span{color:var(--purple)}
.version-badge{background:var(--purple);color:#fff;font-size:9px;
padding:2px 6px;border-radius:4px;letter-spacing:1px}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);
animation:pulse 2s infinite;margin-left:auto}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
/* Sidebar */
.nav-section{font-size:9px;color:var(--muted);letter-spacing:2px;
padding:8px 8px 4px;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:8px;padding:8px 10px;
border-radius:8px;cursor:pointer;color:var(--muted);
transition:all .15s;margin-bottom:2px;user-select:none}
.nav-item:hover{background:var(--surface2);color:var(--text)}
.nav-item.active{background:var(--purple);color:#fff}
.nav-item .icon{width:16px;text-align:center;font-size:14px}
/* Cards */
.card{background:var(--surface);border:1px solid var(--border);
border-radius:12px;padding:16px;margin-bottom:12px}
.card-header{display:flex;align-items:center;justify-content:space-between;
margin-bottom:12px}
.card-title{font-weight:700;color:var(--text);font-size:13px;
display:flex;align-items:center;gap:6px}
/* Grid */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
/* Stats */
.stat-box{background:var(--surface2);border:1px solid var(--border);
border-radius:10px;padding:14px;text-align:center}
.stat-num{font-size:28px;font-weight:800;color:var(--cyan);line-height:1}
.stat-label{font-size:10px;color:var(--muted);margin-top:4px;letter-spacing:1px}
/* Phase cards */
.phase-card{background:var(--surface2);border:1px solid var(--border);
border-radius:10px;padding:14px;position:relative;overflow:hidden;
transition:border-color .2s}
.phase-card.applied{border-color:var(--green)}
.phase-card.running{border-color:var(--yellow)}
.phase-card.disabled{opacity:.45}
.phase-card::before{content:'';position:absolute;top:0;left:0;right:0;
height:2px;background:var(--border)}
.phase-card.applied::before{background:var(--green)}
.phase-card.running::before{background:var(--yellow)}
.phase-tag{font-size:9px;padding:2px 6px;border-radius:4px;letter-spacing:1px;
font-weight:700;text-transform:uppercase}
.tag-foundation{background:#1e3a5f;color:var(--blue)}
.tag-ui{background:#2d1b69;color:var(--purple)}
.tag-logic{background:#1a3a2a;color:var(--green)}
.tag-network{background:#1a2e3a;color:var(--cyan)}
.tag-settings{background:#3a2a1a;color:var(--yellow)}
.tag-release{background:#3a1a1a;color:var(--red)}
.tag-protocol{background:#1f1040;color:#a78bfa)}
.tag-advanced{background:#0f1a2e;color:var(--cyan)}
.phase-num{font-size:32px;font-weight:800;color:var(--dim);
position:absolute;top:10px;right:14px;line-height:1}
.phase-title{font-size:12px;font-weight:700;color:var(--text);margin:6px 0 3px}
.phase-desc{font-size:11px;color:var(--muted);line-height:1.5}
.phase-actions{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;
border:none;border-radius:7px;cursor:pointer;font-size:11px;
font-weight:600;letter-spacing:.3px;transition:all .15s;
font-family:inherit}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:var(--purple);color:#fff}
.btn-primary:hover:not(:disabled){background:#6d28d9}
.btn-ghost{background:transparent;color:var(--muted);
border:1px solid var(--border)}
.btn-ghost:hover:not(:disabled){background:var(--surface2);color:var(--text)}
.btn-success{background:var(--green);color:#fff}
.btn-danger{background:var(--red);color:#fff}
.btn-cyan{background:var(--cyan);color:#000}
.btn-sm{padding:4px 8px;font-size:10px}
/* Terminal */
.terminal{background:#020207;border:1px solid var(--border);border-radius:10px;
font-family:'JetBrains Mono',monospace;font-size:11.5px;
height:380px;overflow-y:auto;padding:14px;line-height:1.6;
color:#c8e6c9}
.terminal .err{color:#ff5252}
.terminal .ok{color:var(--green)}
.terminal .info{color:var(--cyan)}
.terminal .warn{color:var(--yellow)}
/* Badges */
.badge{display:inline-block;padding:2px 7px;border-radius:20px;
font-size:10px;font-weight:600}
.badge-success{background:#052e16;color:var(--green)}
.badge-error{background:#450a0a;color:var(--red)}
.badge-running{background:#422006;color:var(--yellow)}
.badge-pending{background:#1e1e35;color:var(--muted)}
/* Tables */
.table{width:100%;border-collapse:collapse}
.table th{text-align:left;padding:8px 10px;font-size:10px;color:var(--muted);
border-bottom:1px solid var(--border);letter-spacing:1px}
.table td{padding:8px 10px;border-bottom:1px solid var(--border);
font-size:11px}
.table tr:last-child td{border-bottom:none}
.table tr:hover td{background:var(--surface2)}
/* Form */
.form-group{margin-bottom:12px}
.label{display:block;font-size:10px;color:var(--muted);
margin-bottom:5px;letter-spacing:1px;text-transform:uppercase}
.input{width:100%;background:var(--surface2);border:1px solid var(--border);
border-radius:7px;padding:8px 12px;color:var(--text);font-family:inherit;
font-size:12px;outline:none;transition:border-color .15s}
.input:focus{border-color:var(--purple)}
.textarea{resize:vertical;min-height:120px}
/* Progress bar */
.progress-wrap{background:var(--border);border-radius:4px;height:6px;overflow:hidden}
.progress-bar{height:100%;border-radius:4px;
background:linear-gradient(90deg,var(--purple),var(--cyan));
transition:width .4s}
/* Tabs */
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:16px}
.tab{padding:8px 16px;cursor:pointer;color:var(--muted);border-radius:6px 6px 0 0;
font-size:12px;transition:all .15s;user-select:none}
.tab:hover{color:var(--text)}
.tab.active{color:var(--purple);border-bottom:2px solid var(--purple)}
/* Toast */
#toast{position:fixed;bottom:24px;right:24px;z-index:999;
display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast-item{background:var(--surface2);border:1px solid var(--border);
border-radius:10px;padding:10px 16px;font-size:12px;
animation:slideIn .2s;pointer-events:auto;
min-width:240px;max-width:360px}
.toast-item.ok{border-left:3px solid var(--green)}
.toast-item.err{border-left:3px solid var(--red)}
.toast-item.info{border-left:3px solid var(--cyan)}
@keyframes slideIn{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}}
/* Job status indicator */
.job-indicator{display:inline-block;width:8px;height:8px;
border-radius:50%;margin-right:5px}
.ji-success{background:var(--green)}
.ji-error{background:var(--red)}
.ji-running{background:var(--yellow);animation:pulse 1s infinite}
/* Scrollbar */
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-track{background:var(--bg)}
::-webkit-scrollbar-thumb{background:var(--dim);border-radius:2px}
/* Mobile responsive */
@media(max-width:700px){
.app{grid-template-columns:1fr;grid-template-rows:56px auto 1fr}
.sidebar{display:none}
.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}
}
/* Spinner */
.spin{animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* section header */
.section-title{font-size:11px;color:var(--purple);letter-spacing:2px;
font-weight:700;text-transform:uppercase;margin-bottom:12px;
display:flex;align-items:center;gap:8px}
.section-title::after{content:'';flex:1;height:1px;background:var(--border)}
/* hidden */
.hidden{display:none!important}
/* Highlight */
pre{white-space:pre-wrap;word-break:break-all}
</style>
</head>
<body>
<div class="app">
<!-- Topbar -->
<header class="topbar">
<div class="brand">SecFERRO <span>DevOps</span></div>
<span class="version-badge" id="pm-version">v2.0</span>
<span style="font-size:10px;color:var(--muted)" id="top-status">Ładowanie…</span>
<div style="margin-left:auto;display:flex;gap:10px;align-items:center">
<span style="font-size:10px;color:var(--muted)" id="clock"></span>
<div class="status-dot" id="srv-dot"></div>
</div>
</header>
<!-- Sidebar -->
<nav class="sidebar">
<div class="nav-section">Widoki</div>
<div class="nav-item active" onclick="show('dashboard')" id="nav-dashboard">
<span class="icon">📊</span> Dashboard
</div>
<div class="nav-item" onclick="show('phases')" id="nav-phases">
<span class="icon">⚡</span> Fazy
</div>
<div class="nav-item" onclick="show('terminal')" id="nav-terminal">
<span class="icon">💻</span> Terminal
</div>
<div class="nav-item" onclick="show('jobs')" id="nav-jobs">
<span class="icon">📋</span> Zadania
<span id="running-badge" class="badge badge-running hidden" style="margin-left:auto"></span>
</div>
<div class="nav-item" onclick="show('files')" id="nav-files">
<span class="icon">📁</span> Pliki
</div>
<div class="nav-section" style="margin-top:8px">Zarządzanie</div>
<div class="nav-item" onclick="show('changelog')" id="nav-changelog">
<span class="icon">📝</span> Changelog
</div>
<div class="nav-item" onclick="show('version')" id="nav-version">
<span class="icon">🏷️</span> Wersja
</div>
<div class="nav-item" onclick="show('sysinfo')" id="nav-sysinfo">
<span class="icon">🖥️</span> System
</div>
<div class="nav-item" onclick="show('settings')" id="nav-settings">
<span class="icon">⚙️</span> Ustawienia
</div>
</nav>
<!-- Main content -->
<main class="main">
<!-- ── DASHBOARD ── -->
<div id="view-dashboard">
<div class="section-title">📊 Dashboard projektu</div>
<div class="grid-4" id="stat-cards" style="margin-bottom:16px">
<div class="stat-box"><div class="stat-num" id="s-phases">—</div>
<div class="stat-label">FAZ GOTOWYCH</div></div>
<div class="stat-box"><div class="stat-num" id="s-files">—</div>
<div class="stat-label">PLIKÓW KT</div></div>
<div class="stat-box"><div class="stat-num" id="s-total">—</div>
<div class="stat-label">PLIKÓW ŁĄCZNIE</div></div>
<div class="stat-box"><div class="stat-num" id="s-build">—</div>
<div class="stat-label">BUILD #</div></div>
</div>
<!-- Progress -->
<div class="card" style="margin-bottom:12px">
<div class="card-header">
<div class="card-title">🚀 Postęp projektu</div>
<span id="progress-pct" style="color:var(--cyan);font-weight:700">0%</span>
</div>
<div class="progress-wrap">
<div class="progress-bar" id="progress-bar" style="width:0%"></div>
</div>
<div style="display:flex;justify-content:space-between;
margin-top:6px;font-size:10px;color:var(--muted)">
<span id="applied-list">Brak zastosowanych faz</span>
<span id="last-build-time">—</span>
</div>
</div>
<div class="grid-2">
<!-- Quick Run -->
<div class="card">
<div class="card-title" style="margin-bottom:12px">⚡ Szybkie akcje</div>
<div style="display:flex;flex-direction:column;gap:8px">
<button class="btn btn-primary" onclick="runUpdate0()">
▶ Odtwórz projekt (update_0.py --finish)
</button>
<button class="btn btn-ghost" onclick="runDryRun0()">
🔍 Podgląd (update_0.py dry-run)
</button>
<button class="btn btn-ghost" onclick="show('phases')">
⚡ Zarządzaj fazami →
</button>
<button class="btn btn-ghost" onclick="refreshAll()">
🔄 Odśwież status
</button>
</div>
</div>
<!-- Notes -->
<div class="card">
<div class="card-title" style="margin-bottom:12px">📌 Notatki projektu</div>
<textarea id="notes-input" class="input textarea" style="min-height:100px"
placeholder="Zapisz notatki, TODO, informacje o wersji…"></textarea>
<button class="btn btn-ghost btn-sm" style="margin-top:8px"
onclick="saveNotes()">💾 Zapisz notatki</button>
</div>
</div>
<!-- Recent jobs -->
<div class="card">
<div class="card-header">
<div class="card-title">⏱ Ostatnie zadania</div>
<button class="btn btn-ghost btn-sm" onclick="show('jobs')">Wszystkie →</button>
</div>
<table class="table">
<thead><tr>
<th>STATUS</th><th>SKRYPT</th><th>CZAS</th><th>AKCJA</th>
</tr></thead>
<tbody id="recent-jobs-tbody">
<tr><td colspan="4" style="color:var(--muted);text-align:center">
Brak zadań</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── FAZY ── -->
<div id="view-phases" class="hidden">
<div class="section-title">⚡ Zarządzanie fazami</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary" onclick="runAllPending()">▶▶ Uruchom brakujące</button>
<button class="btn btn-ghost" onclick="loadPhases()">🔄 Odśwież</button>
</div>
<div id="phases-grid" class="grid-2">
<div class="card" style="text-align:center;color:var(--muted)">Ładowanie…</div>
</div>
</div>
<!-- ── TERMINAL ── -->
<div id="view-terminal" class="hidden">
<div class="section-title">💻 Terminal — Live output</div>
<div class="card">
<div class="card-header">
<div class="card-title" id="term-job-label">Brak aktywnego zadania</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" onclick="clearTerminal()">🗑 Wyczyść</button>
<button class="btn btn-ghost btn-sm" onclick="scrollTerminal()">⬇ Dół</button>
</div>
</div>
<div class="terminal" id="terminal-output">
<span style="color:var(--muted)">// Uruchom fazę aby zobaczyć output tutaj…</span>
</div>
</div>
<!-- Custom script runner -->
<div class="card">
<div class="card-title" style="margin-bottom:12px">🔧 Uruchom własny skrypt</div>
<div class="grid-2">
<div class="form-group">
<label class="label">Nazwa skryptu (.py)</label>
<input class="input" id="custom-script" placeholder="np. update_1z6.py">
</div>
<div class="form-group">
<label class="label">Argumenty</label>
<input class="input" id="custom-args" placeholder="np. --finish">
</div>
</div>
<button class="btn btn-primary" onclick="runCustomScript()">▶ Uruchom</button>
</div>
</div>
<!-- ── JOBS ── -->
<div id="view-jobs" class="hidden">
<div class="section-title">📋 Historia zadań</div>
<div class="card">
<div class="card-header">
<div class="card-title">Wszystkie zadania</div>
<button class="btn btn-ghost btn-sm" onclick="loadJobs()">🔄 Odśwież</button>
</div>
<table class="table">
<thead><tr>
<th>STATUS</th><th>ETYKIETA</th><th>SKRYPT</th><th>START</th>
<th>CZAS</th><th>EXIT</th><th>AKCJA</th>
</tr></thead>
<tbody id="jobs-tbody">
<tr><td colspan="7" style="color:var(--muted);text-align:center">Brak zadań</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── FILES ── -->
<div id="view-files" class="hidden">
<div class="section-title">📁 Pliki projektu</div>
<div class="card">
<div class="card-header">
<div class="card-title">Zawartość update_0.py (FILES)</div>
<button class="btn btn-ghost btn-sm" onclick="loadFiles()">🔄 Odśwież</button>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:12px">
Pliki zarejestrowane w akumulatorze projektu:
<span id="files-count" style="color:var(--cyan);font-weight:700">—</span>
</div>
<input class="input" id="file-search" placeholder="🔍 Filtruj pliki…"
oninput="filterFiles()" style="margin-bottom:10px">
<div id="files-list" style="max-height:500px;overflow-y:auto">
<div style="color:var(--muted)">Ładowanie…</div>
</div>
</div>
</div>
<!-- ── CHANGELOG ── -->
<div id="view-changelog" class="hidden">
<div class="section-title">📝 Changelog</div>
<div class="card">
<div class="card-header">
<div class="card-title">CHANGELOG.md</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" onclick="loadChangelog()">🔄 Wczytaj</button>
<button class="btn btn-primary btn-sm" onclick="saveChangelog()">💾 Zapisz</button>
</div>
</div>
<textarea id="changelog-editor" class="input textarea"
style="min-height:450px;font-family:monospace;font-size:12px"
placeholder="# Changelog…"></textarea>
</div>
</div>
<!-- ── VERSION ── -->
<div id="view-version" class="hidden">
<div class="section-title">🏷️ Zarządzanie wersją</div>
<div class="grid-2">
<div class="card">
<div class="card-title" style="margin-bottom:16px">
Aktualna wersja:
<span id="cur-version" style="color:var(--cyan);font-size:18px;
font-weight:800;margin-left:8px">—</span>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px">
<button class="btn btn-ghost" onclick="bumpVersion('major')">
Major (+1.0.0)</button>
<button class="btn btn-primary" onclick="bumpVersion('minor')">
Minor (+0.1.0)</button>
<button class="btn btn-cyan" onclick="bumpVersion('patch')">
Patch (+0.0.1)</button>
</div>
<div class="form-group">
<label class="label">Wpisy do changelog</label>
<textarea id="bump-entries" class="input textarea" style="min-height:80px"
placeholder="- Dodano obsługę X&#10;- Naprawiono Y"></textarea>
</div>
<button class="btn btn-primary" onclick="bumpWithEntries()">
🚀 Bump wersji + changelog
</button>
</div>
<div class="card">
<div class="card-title" style="margin-bottom:12px">🔄 Reset stanu</div>
<p style="color:var(--muted);font-size:11px;margin-bottom:12px">
Zresetuj status konkretnej fazy (cofnij zaznaczenie "zastosowano").
</p>
<div class="form-group">
<label class="label">ID fazy do resetu</label>
<input class="input" id="reset-phase-id" placeholder="np. 7">
</div>
<button class="btn btn-danger btn-sm" onclick="resetPhase()">
⚠ Cofnij fazę
</button>
<hr style="border-color:var(--border);margin:16px 0">
<div class="card-title" style="margin-bottom:10px">⚠ Niebezpieczne</div>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="btn btn-danger btn-sm" onclick="cleanTarget('logs')">
🗑 Wyczyść logi (.pm_logs/)</button>
<button class="btn btn-danger btn-sm" onclick="cleanTarget('state')">
🗑 Reset stanu projektu (.pm_state.json)</button>
<button class="btn btn-danger btn-sm"
onclick="if(confirm('USUNĄĆ TVRemoteApp/?'))cleanTarget('project')">
💣 USUŃ katalog TVRemoteApp/</button>
</div>
</div>
</div>
</div>
<!-- ── SYSINFO ── -->
<div id="view-sysinfo" class="hidden">
<div class="section-title">🖥️ Informacje systemowe</div>
<div class="card">
<table class="table" id="sysinfo-table">
<tbody><tr><td colspan="2" style="color:var(--muted)">Ładowanie…</td></tr></tbody>
</table>
</div>
<div class="card">
<div class="card-title" style="margin-bottom:12px">📦 Skrypty na dysku</div>
<table class="table" id="scripts-table">
<thead><tr><th>FAZA</th><th>SKRYPT</th><th>STATUS</th>
<th>ROZMIAR</th><th>ZMODYFIKOWANY</th></tr></thead>
<tbody id="scripts-tbody">
<tr><td colspan="5" style="color:var(--muted)">Ładowanie…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── SETTINGS ── -->
<div id="view-settings" class="hidden">
<div class="section-title">⚙️ Ustawienia</div>
<div class="grid-2">
<div class="card">
<div class="card-title" style="margin-bottom:12px">ℹ️ O projekcie</div>
<table class="table">
<tr><td style="color:var(--muted)">Menadżer</td>
<td style="color:var(--cyan)" id="info-brand">SecFERRO DevOps Center</td></tr>
<tr><td style="color:var(--muted)">Wersja PM</td>
<td id="info-pm-ver">—</td></tr>
<tr><td style="color:var(--muted)">Katalog</td>
<td id="info-cwd" style="word-break:break-all;font-size:10px">—</td></tr>
<tr><td style="color:var(--muted)">Python</td>
<td id="info-py">—</td></tr>
<tr><td style="color:var(--muted)">Platforma</td>
<td id="info-plat" style="font-size:10px">—</td></tr>
</table>
</div>
<div class="card">
<div class="card-title" style="margin-bottom:12px">💡 Skróty klawiszowe</div>
<table class="table">
<tr><td><kbd style="background:var(--border);padding:2px 6px;border-radius:3px">R</kbd></td>
<td style="color:var(--muted)">Odśwież status</td></tr>
<tr><td><kbd style="background:var(--border);padding:2px 6px;border-radius:3px">T</kbd></td>
<td style="color:var(--muted)">Pokaż terminal</td></tr>
<tr><td><kbd style="background:var(--border);padding:2px 6px;border-radius:3px">ESC</kbd></td>
<td style="color:var(--muted)">Wróć do dashboard</td></tr>
</table>
</div>
</div>
</div>
</main><!-- /main -->
</div><!-- /app -->
<!-- Toast container -->
<div id="toast"></div>
<script>
// ──────────────────────────────────────────────────────────────
// STATE
// ──────────────────────────────────────────────────────────────
let currentView = 'dashboard';
let activeJobId = null;
let activeSSE = null;
let allFiles = [];
let refreshTimer = null;
// ──────────────────────────────────────────────────────────────
// NAVIGATION
// ──────────────────────────────────────────────────────────────
function show(view) {
document.querySelectorAll('[id^="view-"]').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('[id^="nav-"]').forEach(el => el.classList.remove('active'));
document.getElementById('view-' + view)?.classList.remove('hidden');
document.getElementById('nav-' + view)?.classList.add('active');
currentView = view;
const loaders = {
'phases' : loadPhases,
'jobs' : loadJobs,
'files' : loadFiles,
'changelog' : loadChangelog,
'sysinfo' : loadSysinfo,
'settings' : loadSettings,
'version' : loadVersion,
};
if (loaders[view]) loaders[view]();
}
// ──────────────────────────────────────────────────────────────
// API HELPERS
// ──────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
try {
const r = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return await r.json();
} catch (e) {
toast('Błąd połączenia z serwerem', 'err');
return null;
}
}
async function post(path, body) {
return api(path, { method: 'POST', body: JSON.stringify(body) });
}
// ──────────────────────────────────────────────────────────────
// DASHBOARD
// ──────────────────────────────────────────────────────────────
async function refreshAll() {
const data = await api('/api/status');
if (!data) return;
// Stats
const stats = data.project_stats || {};
const pDone = data.phases_done || 0;
const pTotal = data.phases_total || 8;
document.getElementById('s-phases').textContent = `${pDone}/${pTotal}`;
document.getElementById('s-files').textContent = stats.kt_files || 0;
document.getElementById('s-total').textContent = stats.total_files || 0;
document.getElementById('s-build').textContent = data.build_number || 0;
// Progress
const pct = Math.round((pDone / pTotal) * 100);
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-pct').textContent = pct + '%';
// Applied phases
const applied = data.applied_phases || [];
document.getElementById('applied-list').textContent =
applied.length ? 'Fazy: ' + applied.join(', ') : 'Brak zastosowanych faz';
// Last build
const lb = data.last_build;
document.getElementById('last-build-time').textContent =
lb ? new Date(lb).toLocaleString('pl') : 'Nigdy';
// Top status
document.getElementById('top-status').textContent =
`Wersja ${data.version} · Build #${data.build_number} · ${pDone}/${pTotal} faz`;
document.getElementById('pm-version').textContent = 'v' + (data.pm_version || '2.0');
// Notes
const notesEl = document.getElementById('notes-input');
if (notesEl && !notesEl.dataset.dirty) {
notesEl.value = data.notes || '';
}
// Running jobs badge
const rb = document.getElementById('running-badge');
if (data.running_jobs > 0) {
rb.textContent = data.running_jobs;
rb.classList.remove('hidden');
} else {
rb.classList.add('hidden');
}
// Recent jobs
await loadRecentJobs();
}
async function loadRecentJobs() {
const jobs = await api('/api/jobs');
if (!jobs) return;
const tbody = document.getElementById('recent-jobs-tbody');
if (!jobs.length) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--muted);text-align:center">Brak zadań</td></tr>';
return;
}
tbody.innerHTML = jobs.slice(0, 6).map(j => `
<tr>
<td><span class="badge badge-${j.status}">${statusIcon(j.status)} ${j.status}</span></td>
<td>${esc(j.label || j.script)}</td>
<td style="color:var(--muted)">${fmtTime(j.started_at)}</td>
<td><button class="btn btn-ghost btn-sm" onclick="viewJobOutput('${j.id}')">Log</button></td>
</tr>`).join('');
}
// ──────────────────────────────────────────────────────────────
// PHASES
// ──────────────────────────────────────────────────────────────
async function loadPhases() {
const phases = await api('/api/phases');
if (!phases) return;
const grid = document.getElementById('phases-grid');
grid.innerHTML = phases.map(ph => `
<div class="phase-card ${ph.applied?'applied':''} ${ph.can_run&&!ph.applied?'':''}
${!ph.can_run&&!ph.applied?'disabled':''}">
<div class="phase-num">${ph.id}</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">
<span class="phase-tag tag-${ph.tag}">${ph.tag}</span>
${ph.applied?'<span class="badge badge-success">✓ Gotowa</span>':''}
${!ph.script_exists?'<span class="badge badge-error">Brak skryptu</span>':''}
${!ph.deps_ok?'<span class="badge badge-pending">Czeka na zależności</span>':''}
</div>
<div class="phase-title">${esc(ph.title)}</div>
<div class="phase-desc">${esc(ph.desc)}</div>
${ph.script_exists?`<div style="font-size:9px;color:var(--muted);margin-top:4px">
${ph.script} · ${ph.script_size}KB · ${ph.script_mtime||'?'}</div>`:''}
<div class="phase-actions">
${ph.can_run && !ph.applied ? `
<button class="btn btn-primary btn-sm" onclick="runPhase('${ph.id}',false)">
▶ Wykonaj</button>
<button class="btn btn-ghost btn-sm" onclick="runPhase('${ph.id}',true)">
🔍 Dry-run</button>` : ''}
${ph.applied ? `
<button class="btn btn-ghost btn-sm" onclick="runPhase('${ph.id}',false)">
🔁 Ponów</button>
<button class="btn btn-ghost btn-sm" onclick="runPhase('${ph.id}',true)">
🔍 Dry-run</button>` : ''}
${!ph.can_run && !ph.applied && ph.script_exists ? `
<span style="font-size:10px;color:var(--muted)">
Wymagane: fazy ${(ph.deps||[]).join(', ')}</span>` : ''}
</div>
</div>`).join('');
}
async function runPhase(phaseId, dryRun) {
const data = await post(`/api/run/${phaseId}`, { dry_run: dryRun });
if (!data || !data.job_id) { toast('Błąd uruchamiania fazy', 'err'); return; }
toast(`${dryRun?'Dry-run':'Uruchomiono'}: ${data.label}`, 'ok');
openTerminal(data.job_id, data.label);
}
async function runAllPending() {
const phases = await api('/api/phases');
if (!phases) return;
const pending = phases.filter(p => !p.applied && p.can_run);
if (!pending.length) { toast('Brak faz do uruchomienia', 'info'); return; }
toast(`Kolejkuję ${pending.length} faz…`, 'info');
for (const ph of pending) {
await runPhase(ph.id, false);
await new Promise(r => setTimeout(r, 800)); // small delay between starts
}
}
// ──────────────────────────────────────────────────────────────
// TERMINAL / SSE
// ──────────────────────────────────────────────────────────────
function openTerminal(jobId, label) {
show('terminal');
document.getElementById('term-job-label').textContent = label || jobId;
const term = document.getElementById('terminal-output');
term.innerHTML = `<span style="color:var(--cyan)">[PM] Starting: ${esc(label)}</span>\n`;
activeJobId = jobId;
if (activeSSE) { activeSSE.close(); activeSSE = null; }
const es = new EventSource(`/api/stream/${jobId}`);
activeSSE = es;
es.addEventListener('line', e => {
const line = JSON.parse(e.data);
appendTermLine(line);
});
es.addEventListener('done', e => {
const status = JSON.parse(e.data);
appendTermLine(`\n[PM] ══ Zakończono: ${status.toUpperCase()} ══`);
es.close();
activeSSE = null;
refreshAll();
if (currentView === 'phases') loadPhases();
});
es.onerror = () => {
appendTermLine('[PM] Połączenie SSE przerwane');
es.close();
activeSSE = null;
};
}
function appendTermLine(line) {
const term = document.getElementById('terminal-output');
const span = document.createElement('span');
const cls = line.includes('❌')||line.includes('Error')||line.includes('error') ? 'err'
: line.includes('✅')||line.includes('zakończon') ? 'ok'
: line.includes('[PM]') ? 'info'
: line.includes('⚠') ? 'warn' : '';
if (cls) span.className = cls;
span.textContent = line;
term.appendChild(span);
term.appendChild(document.createTextNode('\n'));
term.scrollTop = term.scrollHeight;
}
function clearTerminal() {
document.getElementById('terminal-output').innerHTML =
'<span style="color:var(--muted)">// Terminal wyczyszczony</span>';
}
function scrollTerminal() {
const t = document.getElementById('terminal-output');
t.scrollTop = t.scrollHeight;
}
async function viewJobOutput(jobId) {
show('terminal');
document.getElementById('term-job-label').textContent = `Log: ${jobId}`;
const term = document.getElementById('terminal-output');
term.innerHTML = '';
const data = await api(`/api/log/${jobId}`);
if (!data) return;
(data.content || '').split('\n').forEach(l => appendTermLine(l));
}
async function runCustomScript() {
const script = document.getElementById('custom-script').value.trim();
const args = document.getElementById('custom-args').value.trim()
.split(' ').filter(Boolean);
if (!script) { toast('Podaj nazwę skryptu', 'err'); return; }
const data = await post('/api/run-script', { script, args });
if (!data?.job_id) { toast('Błąd uruchamiania', 'err'); return; }
toast(`Uruchomiono: ${script}`, 'ok');
openTerminal(data.job_id, script);
}
async function runUpdate0() {
const data = await post('/api/run-script', { script: 'update_0.py', args: ['--finish'] });
if (!data?.job_id) { toast('Brak pliku update_0.py', 'err'); return; }
toast('Uruchamiam update_0.py --finish', 'ok');
openTerminal(data.job_id, 'update_0.py --finish');
}
async function runDryRun0() {
const data = await post('/api/run-script', { script: 'update_0.py', args: [] });
if (!data?.job_id) { toast('Brak pliku update_0.py', 'err'); return; }
openTerminal(data.job_id, 'update_0.py (dry-run)');
}
// ──────────────────────────────────────────────────────────────
// JOBS
// ──────────────────────────────────────────────────────────────
async function loadJobs() {
const jobs = await api('/api/jobs');
if (!jobs) return;
const tbody = document.getElementById('jobs-tbody');
if (!jobs.length) {
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--muted);text-align:center">Brak zadań</td></tr>';
return;
}
tbody.innerHTML = jobs.map(j => {
const dur = j.finished_at && j.started_at
? Math.round((new Date(j.finished_at)-new Date(j.started_at))/1000) + 's'
: j.status==='running' ? '…' : '—';
return `<tr>
<td><span class="job-indicator ji-${j.status}"></span>
<span class="badge badge-${j.status}">${j.status}</span></td>
<td>${esc(j.label||j.script)}</td>
<td style="color:var(--muted)">${esc(j.script)}</td>
<td style="color:var(--muted)">${fmtTime(j.started_at)}</td>
<td style="color:var(--muted)">${dur}</td>
<td style="color:${j.exit_code===0?'var(--green)':'var(--red)'}">${j.exit_code??'—'}</td>
<td><button class="btn btn-ghost btn-sm" onclick="viewJobOutput('${j.id}')">
📄 Log</button></td>
</tr>`;
}).join('');
}
// ──────────────────────────────────────────────────────────────
// FILES
// ──────────────────────────────────────────────────────────────
async function loadFiles() {
const data = await api('/api/files');
if (!data) return;
allFiles = data.files || [];
document.getElementById('files-count').textContent = allFiles.length;
renderFiles(allFiles);
}
function renderFiles(files) {
const container = document.getElementById('files-list');
if (!files.length) {
container.innerHTML = '<div style="color:var(--muted)">Brak plików (uruchom fazę 1+)</div>';
return;
}
const grouped = {};
files.forEach(f => {
const parts = f.split('/');
const section = parts.length > 3 ? parts.slice(0, -1).join('/') : 'root';
(grouped[section] = grouped[section]||[]).push(f);
});
container.innerHTML = Object.entries(grouped).map(([sec, fs]) => `
<div style="margin-bottom:10px">
<div style="font-size:9px;color:var(--purple);letter-spacing:1px;
font-weight:700;margin-bottom:4px">${esc(sec)}/</div>
${fs.map(f => {
const name = f.split('/').pop();
const ext = name.split('.').pop();
const icon = ext==='kt'?'🟣':ext==='xml'?'🟡':ext==='md'?'📝':'📄';
return `<div style="display:flex;align-items:center;gap:6px;padding:3px 6px;
border-radius:5px;cursor:default"
onmouseover="this.style.background='var(--surface2)'"
onmouseout="this.style.background=''">
<span>${icon}</span>
<span style="color:var(--text);font-size:11px">${esc(name)}</span>
<span style="color:var(--dim);font-size:9px">.${ext}</span>
</div>`;
}).join('')}
</div>`).join('');
}
function filterFiles() {
const q = document.getElementById('file-search').value.toLowerCase();
renderFiles(q ? allFiles.filter(f => f.toLowerCase().includes(q)) : allFiles);
}
// ──────────────────────────────────────────────────────────────
// CHANGELOG
// ──────────────────────────────────────────────────────────────
async function loadChangelog() {
const data = await api('/api/changelog');
if (data) document.getElementById('changelog-editor').value = data.content || '';
}
async function saveChangelog() {
const content = document.getElementById('changelog-editor').value;
const r = await post('/api/changelog', { content });
if (r?.ok) toast('Changelog zapisany', 'ok');
}
// ──────────────────────────────────────────────────────────────
// VERSION
// ──────────────────────────────────────────────────────────────
async function loadVersion() {
const data = await api('/api/status');
if (data) document.getElementById('cur-version').textContent = data.version || '1.0.0';
}
async function bumpVersion(type) {
const r = await post('/api/version', { type });
if (r) {
toast(`Nowa wersja: ${r.version} (build #${r.build_number})`, 'ok');
loadVersion(); refreshAll();
}
}
async function bumpWithEntries() {
const raw = document.getElementById('bump-entries').value.trim();
const entries = raw.split('\n').map(l => l.replace(/^[-*]\s*/,'')).filter(Boolean);
const r = await post('/api/version', { type: 'minor', entries });
if (r) {
toast(`Wersja ${r.version} · changelog zaktualizowany`, 'ok');
loadVersion(); refreshAll(); loadChangelog();
}
}
async function resetPhase() {
const id = document.getElementById('reset-phase-id').value.trim();
if (!id) { toast('Podaj ID fazy', 'err'); return; }
const r = await post('/api/reset-phase', { phase_id: id });
if (r?.ok) { toast(`Faza ${id} cofnięta`, 'info'); refreshAll(); }
}
async function cleanTarget(target) {
const r = await post('/api/clean', { target });
if (r?.ok) toast(r.message || 'Gotowe', 'ok');
else toast(r?.error || 'Błąd', 'err');
refreshAll();
}
// ──────────────────────────────────────────────────────────────
// SYSINFO
// ──────────────────────────────────────────────────────────────
async function loadSysinfo() {
const info = await api('/api/sysinfo');
const phases = await api('/api/phases');
if (info) {
const tbody = document.querySelector('#sysinfo-table tbody');
const free = ((info.free_mb/1024)||0).toFixed(1);
const total = ((info.total_mb/1024)||0).toFixed(1);
tbody.innerHTML = [
['Platforma', info.platform],
['Python', info.python],
['CPU/Arch', info.cpu],
['Host', info.node],
['Katalog roboczy', info.cwd],
['Wolne miejsce', `${free} GB / ${total} GB`],
].map(([k,v]) => `<tr><td style="color:var(--muted);width:160px">${k}</td>
<td style="word-break:break-all">${esc(String(v))}</td></tr>`).join('');
}
if (phases) {
document.getElementById('scripts-tbody').innerHTML = phases.map(ph => `
<tr>
<td style="color:var(--cyan)">#${ph.id}</td>
<td style="font-size:10px">${esc(ph.script)}</td>
<td>${ph.script_exists
? '<span class="badge badge-success">✓ Istnieje</span>'
: '<span class="badge badge-error">Brak</span>'}</td>
<td>${ph.script_size||0} KB</td>
<td style="color:var(--muted)">${ph.script_mtime||'—'}</td>
</tr>`).join('');
}
}
// ──────────────────────────────────────────────────────────────
// SETTINGS / INFO
// ──────────────────────────────────────────────────────────────
async function loadSettings() {
const [info, status] = await Promise.all([api('/api/sysinfo'), api('/api/status')]);
if (info) {
document.getElementById('info-cwd').textContent = info.cwd;
document.getElementById('info-py').textContent = info.python;
document.getElementById('info-plat').textContent = info.platform;
}
if (status) {
document.getElementById('info-pm-ver').textContent = status.pm_version||'—';
}
}
async function saveNotes() {
const notes = document.getElementById('notes-input').value;
const r = await post('/api/notes', { notes });
if (r?.ok) toast('Notatki zapisane', 'ok');
}
// ──────────────────────────────────────────────────────────────
// TOAST
// ──────────────────────────────────────────────────────────────
function toast(msg, type='info') {
const c = document.getElementById('toast');
const el = document.createElement('div');
el.className = `toast-item ${type}`;
el.textContent = msg;
c.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ──────────────────────────────────────────────────────────────
// HELPERS
// ──────────────────────────────────────────────────────────────
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fmtTime(iso) {
if (!iso) return '—';
try { return new Date(iso).toLocaleTimeString('pl'); }
catch { return iso; }
}
function statusIcon(s) {
return s==='success'?'✅':s==='error'?'❌':s==='running'?'⏳':'⏸';
}
function updateClock() {
document.getElementById('clock').textContent = new Date().toLocaleTimeString('pl');
}
// ──────────────────────────────────────────────────────────────
// KEYBOARD SHORTCUTS
// ──────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'r' || e.key === 'R') refreshAll();
if (e.key === 't' || e.key === 'T') show('terminal');
if (e.key === 'Escape') show('dashboard');
});
document.getElementById('notes-input')?.addEventListener('input', function() {
this.dataset.dirty = '1';
});
// ──────────────────────────────────────────────────────────────
// AUTO-REFRESH
// ──────────────────────────────────────────────────────────────
async function autoRefresh() {
await refreshAll();
// Odśwież aktywny widok
if (currentView === 'jobs') loadJobs();
}
// ──────────────────────────────────────────────────────────────
// INIT
// ──────────────────────────────────────────────────────────────
(async function init() {
updateClock();
setInterval(updateClock, 1000);
await refreshAll();
// Auto-refresh co 8 sekund
setInterval(autoRefresh, 8000);
})();
</script>
</body>
</html>"""
# ─────────────────────────────────────────────────────────────────────────────
# SERVER FACTORY
# ─────────────────────────────────────────────────────────────────────────────
def make_server(host: str, port: int) -> HTTPServer:
state = ProjectState()
runner = ScriptRunner(state)
inspector = ProjectInspector()
class Handler(PMHandler):
pass
Handler.state = state
Handler.runner = runner
Handler.inspector = inspector
server = HTTPServer((host, port), Handler)
return server, state, runner
# ─────────────────────────────────────────────────────────────────────────────
# ENTRY POINT
# ─────────────────────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
host = DEFAULT_HOST
port = DEFAULT_PORT
i = 0
while i < len(args):
if args[i] == "--port" and i + 1 < len(args):
port = int(args[i + 1]); i += 2
elif args[i] == "--host" and i + 1 < len(args):
host = args[i + 1]; i += 2
else:
i += 1
server, state, runner = make_server(host, port)
url = f"http://{'localhost' if host in ('0.0.0.0','127.0.0.1') else host}:{port}"
print("\n" + "═" * 62)
print(f" 🛡 {BRAND}")
print(f" 📱 TVRemote Project Manager · v{VERSION}")
print("═" * 62)
print(f" 🌐 Dashboard : {url}")
print(f" 📂 Projekt : {BASE_DIR}")
print(f" 🐍 Python : {sys.version.split()[0]} · {platform.machine()}")
print(f" 📦 Faz : {len(PHASES)}")
print("─" * 62)
print(" Ctrl+C aby zatrzymać serwer")
print("═" * 62 + "\n")
# Inicjalizacja changelog jeśli brak
if not CHANGELOG_FILE.exists():
ChangelogManager.write(f"# Changelog — TVRemote\n\n## [Unreleased]\n\n"
f"## [1.0.0] — {datetime.now().strftime('%Y-%m-%d')}\n"
f"- Inicjalizacja projektu SecFERRO DevOps\n\n")
def handle_signal(sig, frame):
print("\n\n🛑 Zatrzymuję serwer…")
server.shutdown()
state.save()
sys.exit(0)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
# Uruchom w wątku aby SIGINT działał poprawnie
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
t.join()
except KeyboardInterrupt:
handle_signal(None, None)
if __name__ == "__main__":
main()

Wytyczne Bezpieczeństwa (SecFERRO DIVISION)

Środowisko działające na styku przeglądarki WWW oraz powłoki systemowej (szczególnie operujące procesami jak ten Menedżer) musi być traktowane ze szczególną ostrożnością. Poniżej znajduje się oficjalny model zagrożeń SecFERRO.

1. Wytyczne Operacyjne (Network Binding)

Narzędzie project_manager.py zapewnia dostęp do wykonywania arbitralnego kodu na maszynie (RCE - Remote Code Execution). Z założenia, narzędzie działa na adresie pętli zwrotnej 127.0.0.1 (localhost). Jeżeli z przyczyn projektowych wymusisz ekspozycję do sieci poprzez flagę --host 0.0.0.0, pamiętaj, że narzędzie nie posiada autoryzacji HTTP (JWT / BasicAuth). ⛔ ZABRONIONE JEST podnoszenie instancji z flagą 0.0.0.0 w publicznych lub korporacyjnych sieciach WiFi bez ścisłej ochrony firewallem. Każdy użytkownik skanujący podsieć na porcie 8080 będzie w stanie wykonać skrypty na Twoim urządzeniu.

2. Wbudowane Środki Mitygacji (Mitigation Layer)

Aplikacja została zaprojektowana w oparciu o pryncypia Zero-Trust, by chronić pliki wewnątrz lokalnego środowiska dewelopera (chroni przed samym sobą i złośliwymi skryptami wywoławczymi).

Ochrona przed Directory Traversal (Path Traversal)

Punkty wejścia do egzekucji własnych skryptów (np. /api/run-script) są twardo zabezpieczone obiektem Path z biblioteki pathlib. W kodzie Pythona występuje blokada:

# Bezpieczeństwo: tylko pliki .py w BASE_DIR
try:
    script.resolve().relative_to(BASE_DIR)
except ValueError:
    self._send_json({"error": "Niedozwolona ścieżka"}, 403)

Próba wymuszenia egzekucji pliku z innej lokalizacji (np. {"script": "../../../bin/bash"}) wywoła błąd 403 Forbidden. Dodatkowo skrypty są zawsze wywoływane przez absolutną ścieżkę do interpretera sys.executable, a nie poprzez polegający na zmiennej systemowej PATH atak powłoki (shell=False).

3. Zgłaszanie Luk Bezpieczeństwa (Bug Bounty/Reports)

Jeżeli zidentyfikowałeś wektor ataku (np. w omijaniu restrykcji katalogu), nie twórz publicznego żądania Issue. Skontaktuj się prywatnie z liderami dywizji SecFERRO za pośrednictwem funkcji Private Security Advisory na GitHubie.

📋 TVRemote — Priorytetowa lista TODO

Standardy: Google TV App · CetusPlay · AnyMote · androidtvremote2 OSS
Klasyfikacja: P0 (krytyczne) → P3 (rozszerzenia)


🔴 P0 — Krytyczne (produkcja niemożliwa bez tego)

Protokół i połączenie

  • ATV Protocol v2 — TLS + RSA-2048 cert (Faza 7)
  • ForegroundService — połączenie w tle z MediaStyle notification (Faza 7)
  • Exponential backoff retry — 1s→2s→4s→8s→30s max, jitter ±20%
  • Connection health ping — RemoteSetActive co 20s, detect dead socket
  • SSL session resumption — cache TLS session ID, szybsze reconnect
  • Proper protobuf decode — odbieranie NowPlaying state z TV

Bezpieczeństwo i certyfikaty

  • KeyStore migration — AndroidKeyStore API 23+ (bez PKCS12 na dysku)
  • Cert pinning — weryfikacja fingerprint TV cert przy reconnect
  • Pairing timeout — 60s auto-cancel + UX fallback

Stabilność UI

  • Error boundary — globalna obsługa crash w Compose
  • Offline mode graceful — UI działa bez połączenia (wszystkie przyciski disabled, nie crash)
  • Configuration change — ViewModel survives rotation (już OK przez Hilt)

🟠 P1 — Wysoki priorytet (wymagane do konkurowania z CetusPlay/Google TV)

Canvas D-Pad (Faza 8)

  • Custom Canvas D-Pad — okrąg z sektorami, glow przy naciśnięciu
  • Pressure zones — środek = OK, pierścień = directional (jak fizyczny pad)
  • Haptic differentiation — centrum: heavy click, kierunki: light click
  • Long-press acceleration — D-Pad autorepeat 400ms→100ms→60ms

Głos i klawiatura (Faza 8)

  • VoiceSearchManager — SpeechRecognizer API
  • RemoteKeyboardScreen — QWERTY optymalizowany pod TV
  • IME integration — wykryj aktywne pole tekstowe na TV i otwórz klawiaturę
  • Text prediction — Room DB z historią wyszukiwań

Media i Now Playing (Faza 8)

  • NowPlayingBar — tytuł, artysta, miniatura, postęp
  • Media progress slider — seek bar z RemoteMessage seek
  • Volume OSD — nakładka z poziomem głośności (jak systemowy OSD)
  • Playback speed — 0.5x / 1x / 1.25x / 1.5x / 2x

Sieć rozszerzona (Faza 8)

  • Wake on LAN — UDP magic packet na port 9
  • WoL scheduled — zaplanowane wybudzenie (WorkManager)
  • Device ping — ICMP przed połączeniem (sprawdź czy TV online)
  • Multi-device — szybkie przełączanie między zapisanymi TV

🟡 P2 — Średni priorytet (premium differentiator)

Makra i automatyzacja (Faza 8)

  • MacroRecorder — zapis sekwencji przycisków z odstępami czasowymi
  • MacroPlayer — odtwarzanie z możliwością pauzy
  • Macro editor — edycja kroków, zmiana kolejności (drag&drop)
  • Scheduled macros — WorkManager trigger (np. "włącz Netflix o 20:00")
  • Macro share — eksport/import JSON przez share sheet

IR Blaster

  • IrBlasterManager — ConsumerIrManager wrapper
  • IR database — baza kodów dla popularnych TV (Sagemcom, Samsung, LG, Sony)
  • IR learning — zapis własnych kodów przez mikrofon (zaawansowane)
  • Hybrid mode — IR fallback gdy brak Wi-Fi

Pointer Mode

  • Virtual mouse — AccelerometerPointer (gyroscope → cursor movement)
  • Touchpad pointer — absolutne pozycjonowanie kursora na ekranie TV
  • Click zones — lewa/prawa/środkowa kliknięcie przez gesty

App Launcher rozszerzony

  • Live icons — pobieranie ikon z PackageManager TV przez ADB
  • Usage sort — sortowanie aplikacji po częstości użycia (Room counter)
  • Search filter — wyszukiwarka w App Launcher
  • Custom shortcuts — drag&drop reorganizacja ikon

🟢 P3 — Nice to have (ekosystem i UX polish)

Widget Android

  • AppWidget 4x1 — Volume +/- Mute / Play-Pause / power
  • AppWidget 4x2 — miniaturowy pilot z D-Padem

Inteligentne funkcje

  • Smart suggestions — ML Kit: przewidywanie kolejnej aplikacji TV
  • Scene automation — "Tryb film" = przyciemnij światła (Philips Hue API)
  • Sleep timer — wyłącz TV po N minutach (WorkManager)
  • Shake to mute — akcelerometr → KEYCODE_VOLUME_MUTE

Customizacja UI

  • Theme engine — 5 palet kolorów (Purple/Blue/Green/Red/Orange)
  • Button layout editor — drag&drop układu pilota
  • Custom button actions — przypisanie własnych akcji do przycisków
  • D-Pad size slider — regulacja rozmiaru padu (DataStore)

Accessibility

  • TalkBack support — contentDescription na wszystkich przyciskach
  • Large text mode — skalowanie czcionek pilota
  • High contrast — tryb wysokiego kontrastu dla osób z wadami wzroku

🔧 Dług techniczny

# Problem Priorytet Plik
1 FakeRemoteRepository domyślnie w prod KRYTYCZNY di/RemoteModule.kt
2 fallbackToDestructiveMigration() w Room Wysoki di/DatabaseModule.kt
3 TrustAllManager — brak weryfikacji cert TV Wysoki AtvProtocolClient.kt
4 ProtoEncoder — uproszczone kodowanie Średni ProtoEncoder.kt
5 dontobfuscate w ProGuard Przed releasem proguard-rules.pro
6 Brak unit testów dla UseCases Wysoki test/
7 Brak integration testów dla Room DAO Średni androidTest/
8 Brak Baseline Profiles Średni wydajność startu

🧪 Wymagania testowe (standard Google/JetBrains)

test/
├── domain/usecase/       SendCommandUseCaseTest.kt
│                         ObserveConnectionUseCaseTest.kt
├── data/local/           DeviceRepositoryImplTest.kt (Room in-memory)
│                         AppSettingsRepositoryTest.kt (DataStore test)
├── data/remote/          FakeRemoteRepositoryTest.kt
│                         TouchpadGestureProcessorTest.kt
└── ui/viewmodel/         RemoteViewModelTest.kt (Turbine + Coroutines test)
                          DeviceViewModelTest.kt
                          SettingsViewModelTest.kt

androidTest/
├── ui/screens/           RemoteScreenTest.kt (ComposeTestRule)
│                         DeviceListScreenTest.kt
│                         PairingScreenTest.kt
└── data/                 RoomMigrationTest.kt

📊 Benchmarki wydajności (standardy Google TV team)

Metryka Cel Narzędzie
Cold start < 800ms Macrobenchmark
Key command latency < 150ms ADB timing
TLS handshake < 500ms Wireshark
Compose recompositions/s < 60fps stable Layout Inspector
Memory (idle) < 80MB Memory Profiler
Battery drain/h < 2% Battery Historian
ANR rate (prod) < 0.1% Google Play Console
Crash rate (prod) < 0.5% Firebase Crashlytics

📦 Zależności do dodania (weryfikowane 2024-2025)

// Faza 8+
implementation("androidx.work:work-runtime-ktx:2.9.0")      // WorkManager (makra, WoL schedule)
implementation("com.airbnb.android:lottie-compose:6.3.0")   // Animacje
implementation("androidx.media3:media3-session:1.3.0")       // Media session
implementation("com.google.mlkit:smart-reply:16.0.0")        // Smart suggestions (P3)
implementation("io.github.inflationx:calligraphy3:3.1.1")   // (opcjonalne, custom fonts)

// Testing
testImplementation("app.cash.turbine:turbine:1.1.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("com.google.dagger:hilt-android-testing:2.50")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")

SecFERRO Division · FerroART · anonymousik.is-a.dev
Ostatnia aktualizacja: Faza 8 · Standard: Android TV Remote Best Practices 2025

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
╔══════════════════════════════════════════════════════════════════════════╗
║ SecFERRO Division · TVRemote Project Manager · v2.1.0(Flask edition ) ║
║ Zaawansowane centrum zarządzania projektem Android TV Remote ║
╠══════════════════════════════════════════════════════════════════════════╣
║ INSTALACJA (Termux): ║
║ pkg install python ║
║ pip install flask ║
║ python tvremote_manager.py ║
║ ║
║ DOSTĘP: http://localhost:8080 (przeglądarka na telefonie) ║
║ http://0.0.0.0:8080 (sieć lokalna — inny komputer) ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
from __future__ import annotations
import os, sys, json, time, shutil, hashlib, subprocess, threading, queue
import importlib.util, difflib, glob, re
from datetime import datetime
from pathlib import Path
from typing import Iterator
try:
from flask import Flask, jsonify, request, Response, send_from_directory
except ImportError:
print("\n❌ Flask nie znaleziony.")
print(" Zainstaluj: pip install flask\n")
sys.exit(1)
# ════════════════════════════════════════════════════════════════════════
# KONFIGURACJA
# ════════════════════════════════════════════════════════════════════════
BASE_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = BASE_DIR / "TVRemoteApp"
BACKUP_DIR = BASE_DIR / ".tvremote_backups"
LOG_DIR = BASE_DIR / ".tvremote_logs"
META_FILE = BASE_DIR / ".tvremote_meta.json"
PORT = 8080
VERSION = "2.0.0"
for d in (BACKUP_DIR, LOG_DIR):
d.mkdir(exist_ok=True)
app = Flask(__name__)
# ════════════════════════════════════════════════════════════════════════
# GLOBALNY LOG STREAM (SSE)
# ════════════════════════════════════════════════════════════════════════
_log_queue: queue.Queue[str] = queue.Queue(maxsize=500)
_log_history: list[str] = []
_log_lock = threading.Lock()
def _log(msg: str, level: str = "INFO") -> None:
ts = datetime.now().strftime("%H:%M:%S")
icons = {"INFO": "ℹ", "OK": "✅", "WARN": "⚠", "ERR": "❌", "RUN": "▶", "SYS": "⚙"}
icon = icons.get(level, "·")
line = json.dumps({"ts": ts, "level": level, "icon": icon, "msg": msg})
with _log_lock:
_log_history.append(line)
if len(_log_history) > 1000:
_log_history.pop(0)
try:
_log_queue.put_nowait(line)
except queue.Full:
pass
# ════════════════════════════════════════════════════════════════════════
# METADANE PROJEKTU
# ════════════════════════════════════════════════════════════════════════
def _load_meta() -> dict:
if META_FILE.exists():
try:
return json.loads(META_FILE.read_text())
except Exception:
pass
return {"phases_applied": [], "last_action": None, "created": None, "backups": []}
def _save_meta(meta: dict) -> None:
META_FILE.write_text(json.dumps(meta, indent=2, ensure_ascii=False))
# ════════════════════════════════════════════════════════════════════════
# WYKRYWANIE SKRYPTÓW UPDATE
# ════════════════════════════════════════════════════════════════════════
_PHASE_RE = re.compile(r"update_(\d+z\d+|\d+)\.py$", re.I)
def _find_update_scripts() -> list[dict]:
scripts = []
for path in sorted(BASE_DIR.glob("update_*.py")):
name = path.name
if name == "update_0.py":
continue
m = _PHASE_RE.search(name)
tag = m.group(1) if m else name
desc = _extract_script_description(path)
meta = _load_meta()
scripts.append({
"name" : name,
"path" : str(path),
"tag" : tag,
"desc" : desc,
"applied" : name in meta.get("phases_applied", []),
"size" : path.stat().st_size,
"mtime" : datetime.fromtimestamp(path.stat().st_mtime).strftime("%d.%m %H:%M"),
"files" : _count_phase_files(path),
})
return scripts
def _extract_script_description(path: Path) -> str:
"""Wyciąga linię z opisem fazy z docstringa lub komentarzy."""
try:
lines = path.read_text(encoding="utf-8").splitlines()
for line in lines[:20]:
s = line.strip().strip("║").strip()
if ("Faza" in s or "FAZA" in s) and len(s) > 5:
return s[:72]
except Exception:
pass
return path.stem
def _count_phase_files(path: Path) -> int:
"""Liczy pliki zdefiniowane w skrypcie (klucze słownika P)."""
try:
src = path.read_text(encoding="utf-8")
return src.count('P[f"') + src.count("P['") + src.count('P["')
except Exception:
return 0
# ════════════════════════════════════════════════════════════════════════
# SILNIK WYKONANIA FAZY
# ════════════════════════════════════════════════════════════════════════
_running_job: dict | None = None
_job_lock = threading.Lock()
def _run_phase_async(script_path: str, dry_run: bool = False) -> dict:
global _running_job
with _job_lock:
if _running_job and _running_job.get("running"):
return {"ok": False, "error": "Inne zadanie jest w toku"}
_running_job = {"running": True, "script": script_path, "started": time.time()}
def worker():
global _running_job
name = Path(script_path).name
_log(f"Uruchamianie: {name} {'(dry-run)' if dry_run else '(--finish)'}", "RUN")
cmd = [sys.executable, script_path] + ([] if dry_run else ["--finish"])
t0 = time.time()
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
cwd=str(BASE_DIR), text=True, bufsize=1,
)
for line in proc.stdout:
line = line.rstrip()
if line:
lvl = "OK" if "✅" in line else "ERR" if "❌" in line else "INFO"
_log(line, lvl)
proc.wait()
elapsed = round(time.time() - t0, 2)
if proc.returncode == 0:
_log(f"✅ {name} zakończony ({elapsed}s)", "OK")
if not dry_run:
meta = _load_meta()
if name not in meta["phases_applied"]:
meta["phases_applied"].append(name)
meta["last_action"] = {"type": "apply", "script": name, "ts": datetime.now().isoformat()}
_save_meta(meta)
_write_log_file(name, elapsed)
else:
_log(f"❌ {name} błąd (kod {proc.returncode})", "ERR")
except Exception as e:
_log(f"❌ Wyjątek: {e}", "ERR")
finally:
with _job_lock:
_running_job = {"running": False}
threading.Thread(target=worker, daemon=True).start()
return {"ok": True, "msg": f"Uruchomiono: {Path(script_path).name}"}
def _write_log_file(script_name: str, elapsed: float) -> None:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = LOG_DIR / f"{ts}_{script_name}.log"
lines = []
with _log_lock:
lines = list(_log_history[-200:])
path.write_text("\n".join(json.loads(l)["msg"] for l in lines), encoding="utf-8")
# ════════════════════════════════════════════════════════════════════════
# BACKUP / RESTORE
# ════════════════════════════════════════════════════════════════════════
def _create_backup(label: str = "") -> dict:
if not PROJECT_ROOT.exists():
return {"ok": False, "error": "Projekt nie istnieje (nie uruchomiono żadnej fazy)"}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
name = f"backup_{ts}" + (f"_{label}" if label else "")
dest = BACKUP_DIR / name
try:
shutil.copytree(str(PROJECT_ROOT), str(dest))
# Zapisz też update_0.py
u0 = BASE_DIR / "update_0.py"
if u0.exists():
shutil.copy2(str(u0), str(dest / "_update_0.py"))
size = sum(f.stat().st_size for f in dest.rglob("*") if f.is_file())
meta = _load_meta()
entry = {"name": name, "ts": ts, "label": label, "size": size,
"phases": list(meta.get("phases_applied", []))}
meta.setdefault("backups", []).append(entry)
_save_meta(meta)
_log(f"✅ Backup: {name} ({_fmt_size(size)})", "OK")
return {"ok": True, "name": name, "size": _fmt_size(size)}
except Exception as e:
_log(f"❌ Backup error: {e}", "ERR")
return {"ok": False, "error": str(e)}
def _restore_backup(name: str) -> dict:
src = BACKUP_DIR / name
if not src.exists():
return {"ok": False, "error": f"Backup '{name}' nie istnieje"}
try:
if PROJECT_ROOT.exists():
shutil.rmtree(str(PROJECT_ROOT))
shutil.copytree(str(src), str(PROJECT_ROOT),
ignore=shutil.ignore_patterns("_update_0.py"))
u0_src = src / "_update_0.py"
if u0_src.exists():
shutil.copy2(str(u0_src), str(BASE_DIR / "update_0.py"))
_log(f"✅ Przywrócono backup: {name}", "OK")
return {"ok": True}
except Exception as e:
_log(f"❌ Restore error: {e}", "ERR")
return {"ok": False, "error": str(e)}
def _list_backups() -> list[dict]:
meta = _load_meta()
return meta.get("backups", [])
# ════════════════════════════════════════════════════════════════════════
# PRZEGLĄDARKA PLIKÓW
# ════════════════════════════════════════════════════════════════════════
def _list_files(rel_path: str = "") -> dict:
base = PROJECT_ROOT / rel_path if rel_path else PROJECT_ROOT
if not base.exists():
return {"ok": False, "error": "Ścieżka nie istnieje", "items": []}
items = []
try:
for p in sorted(base.iterdir(), key=lambda x: (x.is_file(), x.name.lower())):
items.append({
"name" : p.name,
"path" : str(p.relative_to(PROJECT_ROOT)),
"is_dir": p.is_dir(),
"size" : _fmt_size(p.stat().st_size) if p.is_file() else "",
"mtime" : datetime.fromtimestamp(p.stat().st_mtime).strftime("%d.%m %H:%M"),
"ext" : p.suffix.lstrip(".") if p.is_file() else "",
})
except PermissionError:
pass
return {"ok": True, "path": rel_path, "items": items}
def _read_file(rel_path: str) -> dict:
target = PROJECT_ROOT / rel_path
if not target.exists() or target.is_dir():
return {"ok": False, "error": "Plik nie istnieje"}
try:
size = target.stat().st_size
if size > 512_000:
return {"ok": False, "error": f"Plik zbyt duży ({_fmt_size(size)}) — otwórz ręcznie"}
content = target.read_text(encoding="utf-8", errors="replace")
return {"ok": True, "path": rel_path, "content": content, "size": _fmt_size(size),
"ext": target.suffix.lstrip(".")}
except Exception as e:
return {"ok": False, "error": str(e)}
# ════════════════════════════════════════════════════════════════════════
# DIFF VIEWER
# ════════════════════════════════════════════════════════════════════════
def _get_phase_diff(script_name: str) -> dict:
"""Zwraca listę plików które faza doda/nadpisze względem update_0."""
script_path = BASE_DIR / script_name
if not script_path.exists():
return {"ok": False, "error": "Skrypt nie istnieje"}
try:
spec = importlib.util.spec_from_file_location("_tmp", script_path)
mod = importlib.util.module_from_spec(spec)
# Nie wykonujemy — parsujemy P słownik statycznie
src = script_path.read_text(encoding="utf-8")
# Wyciąg kluczy P[...] przez regex
keys = re.findall(r'P\[f?"([^"]+)"\]', src)
keys += re.findall(r"P\[f?'([^']+)'\]", src)
diffs = []
for key in keys:
key = key.replace("{S}", "app/src/main/java/com/ferroart/tvremote")
key = key.replace("{R}", "app/src/main/res")
target = PROJECT_ROOT / key
status = "new" if not target.exists() else "update"
diffs.append({"path": key, "status": status})
return {"ok": True, "files": diffs, "count": len(diffs)}
except Exception as e:
return {"ok": False, "error": str(e)}
# ════════════════════════════════════════════════════════════════════════
# COMPOSER — NOWE SKRYPTY UPDATE
# ════════════════════════════════════════════════════════════════════════
_COMPOSER_TEMPLATE = '''#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
╔══════════════════════════════════════════════════════════════════╗
║ TVRemote · {script_name} ║
║ {description}
╚══════════════════════════════════════════════════════════════════╝
"""
import sys, importlib.util
from pathlib import Path
FINISH = "--finish" in sys.argv
ROOT = Path("TVRemoteApp")
S = "app/src/main/java/com/ferroart/tvremote"
R = "app/src/main/res"
P: dict[str, str] = {{}}
{file_blocks}
# ════ INFRASTRUKTURA ════════════════════════════════════════════
_SEP = "# " + "═" * 68 + "\\n"
def _load():
try:
spec = importlib.util.spec_from_file_location("update_0", Path("update_0.py"))
if not spec or not spec.loader: return {{}}
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
return dict(getattr(mod, "FILES", {{}}))
except: return {{}}
def _write(files):
for p, c in files.items():
f = ROOT / p; f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(c, encoding="utf-8"); print(f" ✅ {{p}}")
return len(files)
def _serialize(all_files):
tq = "\'\'\'"; out = ""
for path, content in all_files.items():
out += (f"FILES[{{repr(path)}}] = r{{tq}}{{content.lstrip()}}{{tq}}\\n\\n"
if tq not in content else f"FILES[{{repr(path)}}] = {{repr(content)}}\\n\\n")
return out
def _save_update0(all_files):
h = ("#!/usr/bin/env python3\\n# TVRemote — Akumulator\\nimport sys\\nfrom pathlib import Path\\n\\n"
\'FINISH=\\"--finish\\" in sys.argv\\nROOT=Path(\\"TVRemoteApp\\")\\nFILES:dict[str,str]={}\\n\\n\' + _SEP
+ "# PLIKI\\n" + _SEP + "\\n")
f = ("\\n" + _SEP + "\\ndef _main():\\n if FINISH:\\n c=0\\n"
" for p,x in FILES.items():\\n f=ROOT/p\\n"
" f.parent.mkdir(parents=True,exist_ok=True)\\n"
\' f.write_text(x,encoding=\\"utf-8\\");print(f\\" ✅ {p}\\");c+=1\\n\'
\' print(f\\"\\\\n✨ {c}/{len(FILES)} plików\\")\\n\'
" else:\\n [print(f\\" 📄 {p}\\") for p in FILES]\\n"
"if __name__==\'__main__\':\\n _main()\\n")
Path("update_0.py").write_text(h + _serialize(all_files) + f, encoding="utf-8")
print(" 🔄 update_0.py zaktualizowany")
if __name__ == "__main__":
if not FINISH:
print(f"\\n📋 {script_name} — {{len(P)}} plików:")
[print(f" 📄 {{p}}") for p in P]
print("\\nUruchom z --finish aby zapisać."); sys.exit(0)
print(f"\\n🚀 {{Path(__file__).name}}")
existing = _load()
all_files = {{**existing, **P}}
_write(P)
_save_update0(all_files)
print(f"\\n✨ Gotowe! {{len(P)}} plików dodanych/zaktualizowanych")
print(f" Projekt łącznie: {{len(all_files)}} plików")
'''
def _compose_update(data: dict) -> dict:
"""Tworzy nowy skrypt update na podstawie danych z formularza."""
name = data.get("name", "").strip()
desc = data.get("description", "").strip()
files = data.get("files", []) # [{path, content}]
if not name:
return {"ok": False, "error": "Nazwa skryptu jest wymagana"}
if not name.endswith(".py"):
name += ".py"
if not name.startswith("update_"):
name = "update_" + name
target = BASE_DIR / name
if target.exists():
return {"ok": False, "error": f"Plik '{name}' już istnieje"}
file_blocks = ""
for i, f in enumerate(files):
path = f.get("path", "").strip()
content = f.get("content", "")
if not path:
continue
var = f'P["{path}"]'
safe_content = content.replace("'''", "\"\"\"")
file_blocks += f"# ── [{i+1}/{len(files)}] {Path(path).name}\n"
file_blocks += f"{var} = r'''{safe_content}\n'''\n\n"
script = _COMPOSER_TEMPLATE.format(
script_name = name,
description = desc or "Niestandardowa aktualizacja",
file_blocks = file_blocks if file_blocks else '# Dodaj pliki: P["ścieżka"] = r"""..."""\n',
)
target.write_text(script, encoding="utf-8")
_log(f"✅ Utworzono skrypt: {name}", "OK")
return {"ok": True, "name": name, "path": str(target)}
# ════════════════════════════════════════════════════════════════════════
# TERMINAL (ograniczony)
# ════════════════════════════════════════════════════════════════════════
_ALLOWED_CMDS = {
"ls", "cat", "pwd", "echo", "find", "grep", "wc", "head", "tail",
"python", "python3", "pip", "pip3", "du", "df", "date", "uname",
"which", "stat", "file", "diff", "mkdir", "cp", "mv", "rm",
"adb", "gradle", "./gradlew", "gradlew",
}
def _run_shell(cmd: str) -> dict:
cmd = cmd.strip()
first = cmd.split()[0].split("/")[-1] if cmd else ""
if first not in _ALLOWED_CMDS:
return {"ok": False, "error": f"Komenda '{first}' nie jest dozwolona",
"allowed": sorted(_ALLOWED_CMDS)}
try:
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True,
cwd=str(BASE_DIR), timeout=30,
)
output = (result.stdout + result.stderr).strip()
_log(f"$ {cmd}", "SYS")
if output:
for line in output.splitlines()[:50]:
_log(line, "INFO")
return {"ok": True, "output": output[:8000], "code": result.returncode}
except subprocess.TimeoutExpired:
return {"ok": False, "error": "Timeout (30s)"}
except Exception as e:
return {"ok": False, "error": str(e)}
# ════════════════════════════════════════════════════════════════════════
# STATYSTYKI PROJEKTU
# ════════════════════════════════════════════════════════════════════════
def _project_stats() -> dict:
meta = _load_meta()
scripts = _find_update_scripts()
applied = meta.get("phases_applied", [])
total_files = 0
total_size = 0
kt_files = 0
if PROJECT_ROOT.exists():
for f in PROJECT_ROOT.rglob("*"):
if f.is_file():
total_files += 1
total_size += f.stat().st_size
if f.suffix == ".kt":
kt_files += 1
backups = _list_backups()
u0 = BASE_DIR / "update_0.py"
return {
"version" : VERSION,
"project_exists": PROJECT_ROOT.exists(),
"phases_total" : len(scripts),
"phases_applied": len(applied),
"applied_names" : applied,
"total_files" : total_files,
"kt_files" : kt_files,
"total_size" : _fmt_size(total_size),
"backups_count": len(backups),
"has_update0" : u0.exists(),
"update0_size" : _fmt_size(u0.stat().st_size) if u0.exists() else "—",
"last_action" : meta.get("last_action"),
"running" : bool(_running_job and _running_job.get("running")),
"log_count" : len(LOG_DIR.glob("*.log")),
"termux" : "aarch64" in os.uname().machine,
"python" : sys.version.split()[0],
}
def _fmt_size(b: int) -> str:
for u in ("B", "KB", "MB", "GB"):
if b < 1024: return f"{b:.1f} {u}"
b /= 1024
return f"{b:.1f} TB"
# ════════════════════════════════════════════════════════════════════════
# REST API
# ════════════════════════════════════════════════════════════════════════
@app.route("/api/status")
def api_status():
return jsonify(_project_stats())
@app.route("/api/phases")
def api_phases():
return jsonify({"ok": True, "phases": _find_update_scripts()})
@app.route("/api/phases/<name>/run", methods=["POST"])
def api_phase_run(name: str):
path = BASE_DIR / name
if not path.exists():
return jsonify({"ok": False, "error": "Skrypt nie istnieje"}), 404
dry = request.json.get("dry_run", False) if request.json else False
result = _run_phase_async(str(path), dry_run=dry)
return jsonify(result)
@app.route("/api/phases/<name>/diff")
def api_phase_diff(name: str):
return jsonify(_get_phase_diff(name))
@app.route("/api/phases/<name>/mark", methods=["POST"])
def api_phase_mark(name: str):
"""Ręczne oznaczenie fazy jako zastosowanej/niezastosowanej."""
applied = request.json.get("applied", True) if request.json else True
meta = _load_meta()
if applied and name not in meta["phases_applied"]:
meta["phases_applied"].append(name)
elif not applied and name in meta["phases_applied"]:
meta["phases_applied"].remove(name)
_save_meta(meta)
return jsonify({"ok": True})
@app.route("/api/files")
def api_files():
path = request.args.get("path", "")
return jsonify(_list_files(path))
@app.route("/api/files/read")
def api_file_read():
path = request.args.get("path", "")
return jsonify(_read_file(path))
@app.route("/api/files/save", methods=["POST"])
def api_file_save():
data = request.json or {}
rel = data.get("path", "")
content = data.get("content", "")
if not rel:
return jsonify({"ok": False, "error": "Brak ścieżki"})
target = PROJECT_ROOT / rel
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
_log(f"💾 Zapisano: {rel}", "OK")
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/backup/create", methods=["POST"])
def api_backup_create():
label = (request.json or {}).get("label", "")
return jsonify(_create_backup(label))
@app.route("/api/backup/list")
def api_backup_list():
return jsonify({"ok": True, "backups": _list_backups()})
@app.route("/api/backup/restore", methods=["POST"])
def api_backup_restore():
name = (request.json or {}).get("name", "")
return jsonify(_restore_backup(name))
@app.route("/api/backup/delete", methods=["POST"])
def api_backup_delete():
name = (request.json or {}).get("name", "")
path = BACKUP_DIR / name
if not path.exists():
return jsonify({"ok": False, "error": "Backup nie istnieje"})
try:
shutil.rmtree(str(path))
meta = _load_meta()
meta["backups"] = [b for b in meta.get("backups", []) if b["name"] != name]
_save_meta(meta)
_log(f"🗑️ Usunięto backup: {name}", "WARN")
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/logs/history")
def api_logs_history():
with _log_lock:
return jsonify({"ok": True, "logs": list(_log_history[-300:])})
@app.route("/api/logs/files")
def api_logs_files():
logs = sorted(LOG_DIR.glob("*.log"), reverse=True)
return jsonify({"ok": True, "files": [
{"name": f.name, "size": _fmt_size(f.stat().st_size),
"mtime": datetime.fromtimestamp(f.stat().st_mtime).strftime("%d.%m %H:%M")}
for f in logs[:30]
]})
@app.route("/api/logs/stream")
def api_logs_stream():
"""Server-Sent Events — real-time log stream."""
def generate() -> Iterator[str]:
# Wyślij historię przy połączeniu
with _log_lock:
for line in _log_history[-50:]:
yield f"data: {line}\n\n"
# Stream nowych logów
while True:
try:
line = _log_queue.get(timeout=25)
yield f"data: {line}\n\n"
except queue.Empty:
yield "data: {\"ts\":\"\",\"level\":\"PING\",\"icon\":\"·\",\"msg\":\"\"}\n\n"
return Response(generate(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@app.route("/api/compose", methods=["POST"])
def api_compose():
return jsonify(_compose_update(request.json or {}))
@app.route("/api/shell", methods=["POST"])
def api_shell():
cmd = (request.json or {}).get("cmd", "")
return jsonify(_run_shell(cmd))
@app.route("/api/reset", methods=["POST"])
def api_reset():
"""Resetuje metadane projektu (nie usuwa plików)."""
_save_meta({"phases_applied": [], "last_action": None, "created": None, "backups": []})
_log("⚠️ Metadane projektu zresetowane", "WARN")
return jsonify({"ok": True})
@app.route("/api/project/wipe", methods=["POST"])
def api_project_wipe():
"""Usuwa cały folder TVRemoteApp (niebezpieczne!)."""
confirm = (request.json or {}).get("confirm", False)
if not confirm:
return jsonify({"ok": False, "error": "Wymagane potwierdzenie: {confirm: true}"})
if PROJECT_ROOT.exists():
shutil.rmtree(str(PROJECT_ROOT))
_save_meta({"phases_applied": [], "last_action": {"type": "wipe", "ts": datetime.now().isoformat()},
"created": None, "backups": _load_meta().get("backups", [])})
_log("🗑️ Projekt usunięty (TVRemoteApp/)", "WARN")
return jsonify({"ok": True})
# ════════════════════════════════════════════════════════════════════════
# FRONTEND — Single Page Application (embedded HTML)
# ════════════════════════════════════════════════════════════════════════
HTML = r"""<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>SecFERRO · TVRemote Manager</title>
<style>
:root{
--bg:#080810;--surface:#10101A;--surface2:#1A1A2E;--elevated:#1E1E32;
--purple:#7C3AED;--blue:#2563EB;--cyan:#06B6D4;
--green:#10B981;--red:#EF4444;--yellow:#F59E0B;--orange:#F97316;
--text:#E2E8F0;--muted:#64748B;--disabled:#334155;--white:#FFFFFF;
--border:rgba(124,58,237,.25);--glow:rgba(124,58,237,.15);
--font:'JetBrains Mono',monospace;
--r:10px;--r2:6px;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;
min-height:100vh;overflow-x:hidden}
a{color:var(--cyan);text-decoration:none}
/* ── LAYOUT ── */
#app{display:flex;flex-direction:column;min-height:100vh}
#topbar{background:var(--surface);border-bottom:1px solid var(--border);
padding:10px 16px;display:flex;align-items:center;gap:12px;
position:sticky;top:0;z-index:100}
#topbar .logo{font-size:15px;font-weight:700;color:var(--purple);letter-spacing:.05em}
#topbar .sub{color:var(--muted);font-size:10px}
#topbar .spacer{flex:1}
#topbar .status-dot{width:8px;height:8px;border-radius:50%;background:var(--disabled)}
#topbar .status-dot.connected{background:var(--green);box-shadow:0 0 8px var(--green)}
#topbar .status-dot.running{background:var(--yellow);animation:pulse 1s infinite}
#nav{display:flex;overflow-x:auto;background:var(--surface);
border-bottom:1px solid var(--border);padding:0 8px;gap:2px;
scrollbar-width:none}
#nav::-webkit-scrollbar{display:none}
.nav-btn{padding:8px 14px;border:none;background:transparent;color:var(--muted);
cursor:pointer;font-family:var(--font);font-size:11px;font-weight:600;
letter-spacing:.08em;border-bottom:2px solid transparent;white-space:nowrap;
transition:.2s;text-transform:uppercase}
.nav-btn.active{color:var(--purple);border-bottom-color:var(--purple)}
.nav-btn:hover{color:var(--text)}
#content{flex:1;padding:16px;max-width:900px;width:100%;margin:0 auto}
/* ── CARDS ── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
padding:14px;margin-bottom:12px}
.card-title{font-size:10px;font-weight:700;color:var(--purple);letter-spacing:.12em;
text-transform:uppercase;margin-bottom:10px}
.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px}
/* ── STAT TILES ── */
.stat{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r2);
padding:10px 12px;text-align:center}
.stat .val{font-size:22px;font-weight:700;color:var(--purple)}
.stat .lbl{font-size:10px;color:var(--muted);margin-top:2px;text-transform:uppercase;letter-spacing:.05em}
/* ── PHASE CARDS ── */
.phase-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
padding:12px;margin-bottom:8px;transition:.2s}
.phase-card.applied{border-color:rgba(16,185,129,.3)}
.phase-card .ph-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.phase-card .ph-tag{font-size:11px;font-weight:700;color:var(--cyan);
background:rgba(6,182,212,.12);padding:2px 8px;border-radius:4px;
border:1px solid rgba(6,182,212,.25)}
.phase-card .ph-desc{flex:1;font-size:11px;color:var(--muted);line-height:1.4}
.phase-card .ph-badge{font-size:9px;padding:2px 6px;border-radius:3px;font-weight:700;
text-transform:uppercase;letter-spacing:.05em}
.ph-badge.applied{background:rgba(16,185,129,.15);color:var(--green);border:1px solid rgba(16,185,129,.3)}
.ph-badge.pending{background:rgba(100,116,139,.1);color:var(--muted);border:1px solid var(--border)}
.phase-card .ph-meta{display:flex;gap:12px;font-size:10px;color:var(--disabled);margin-bottom:10px}
.phase-card .ph-actions{display:flex;gap:6px;flex-wrap:wrap}
/* ── BUTTONS ── */
.btn{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;
border:none;border-radius:var(--r2);cursor:pointer;font-family:var(--font);
font-size:11px;font-weight:600;transition:.15s;letter-spacing:.03em}
.btn:active{transform:scale(.95)}
.btn-primary{background:var(--purple);color:var(--white)}
.btn-primary:hover{background:#6D28D9}
.btn-outline{background:transparent;color:var(--purple);border:1px solid var(--border)}
.btn-outline:hover{border-color:var(--purple)}
.btn-danger{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3)}
.btn-danger:hover{background:rgba(239,68,68,.25)}
.btn-success{background:var(--green);color:var(--white)}
.btn-cyan{background:rgba(6,182,212,.15);color:var(--cyan);border:1px solid rgba(6,182,212,.3)}
.btn-sm{padding:4px 9px;font-size:10px}
.btn:disabled{opacity:.4;pointer-events:none}
/* ── PROGRESS BAR ── */
.progress-wrap{background:var(--elevated);border-radius:99px;height:6px;overflow:hidden}
.progress-bar{height:100%;border-radius:99px;transition:.4s;
background:linear-gradient(90deg,var(--purple),var(--cyan))}
/* ── LOG PANEL ── */
#log-container{background:#050508;border:1px solid var(--border);border-radius:var(--r);
height:320px;overflow-y:auto;padding:10px;font-size:11px;
font-family:var(--font);line-height:1.6;scrollbar-width:thin;
scrollbar-color:var(--disabled) transparent}
.log-line{display:flex;gap:8px;padding:1px 0}
.log-ts{color:var(--disabled);min-width:54px}
.log-icon{min-width:16px}
.log-msg{color:var(--text);word-break:break-all;flex:1}
.log-line.OK .log-msg{color:var(--green)}
.log-line.ERR .log-msg{color:var(--red)}
.log-line.WARN .log-msg{color:var(--yellow)}
.log-line.RUN .log-msg{color:var(--cyan)}
.log-line.SYS .log-msg{color:var(--purple)}
.log-line.PING{display:none}
/* ── FILE TREE ── */
.file-item{display:flex;align-items:center;gap:8px;padding:6px 8px;
border-radius:var(--r2);cursor:pointer;transition:.15s;border:1px solid transparent}
.file-item:hover{background:var(--surface2);border-color:var(--border)}
.file-item .fi-icon{font-size:14px;min-width:20px;text-align:center}
.file-item .fi-name{flex:1;font-size:12px}
.file-item .fi-meta{font-size:10px;color:var(--muted)}
.breadcrumb{display:flex;align-items:center;gap:4px;font-size:11px;
color:var(--muted);margin-bottom:10px;flex-wrap:wrap}
.breadcrumb span{cursor:pointer;color:var(--cyan)}
.breadcrumb span:hover{text-decoration:underline}
/* ── CODE EDITOR ── */
#code-editor{width:100%;min-height:280px;background:#050508;color:var(--text);
border:1px solid var(--border);border-radius:var(--r2);padding:10px;
font-family:var(--font);font-size:12px;line-height:1.6;resize:vertical;
outline:none}
#code-editor:focus{border-color:var(--purple)}
/* ── TERMINAL ── */
#terminal-output{background:#050508;border:1px solid var(--border);border-radius:var(--r);
padding:10px;min-height:160px;max-height:360px;overflow-y:auto;
font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-all;
color:var(--green)}
#terminal-input{width:100%;background:var(--surface2);border:1px solid var(--border);
border-radius:var(--r2);padding:8px 12px;color:var(--text);
font-family:var(--font);font-size:12px;outline:none;margin-top:6px}
#terminal-input:focus{border-color:var(--purple)}
/* ── FORMS ── */
.form-row{margin-bottom:10px}
.form-label{display:block;font-size:10px;color:var(--muted);margin-bottom:4px;
text-transform:uppercase;letter-spacing:.06em}
.form-input{width:100%;background:var(--surface2);border:1px solid var(--border);
border-radius:var(--r2);padding:8px 10px;color:var(--text);
font-family:var(--font);font-size:12px;outline:none}
.form-input:focus{border-color:var(--purple)}
.form-textarea{min-height:120px;resize:vertical}
/* ── DIFF VIEW ── */
.diff-item{display:flex;align-items:center;gap:8px;padding:5px 8px;
border-radius:var(--r2);font-size:11px;margin-bottom:3px}
.diff-item.new{background:rgba(16,185,129,.08);border-left:3px solid var(--green)}
.diff-item.update{background:rgba(124,58,237,.08);border-left:3px solid var(--purple)}
.diff-badge{font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700;text-transform:uppercase}
.diff-badge.new{background:rgba(16,185,129,.2);color:var(--green)}
.diff-badge.update{background:rgba(124,58,237,.2);color:var(--purple)}
/* ── BACKUP ── */
.backup-item{display:flex;align-items:center;gap:8px;padding:8px 10px;
background:var(--surface2);border-radius:var(--r2);margin-bottom:6px}
.backup-info{flex:1}
.backup-name{font-size:11px;font-weight:600;color:var(--text)}
.backup-meta{font-size:10px;color:var(--muted)}
/* ── TOAST ── */
#toast{position:fixed;bottom:20px;right:16px;left:16px;max-width:380px;margin:0 auto;
background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
padding:10px 14px;font-size:12px;transform:translateY(80px);opacity:0;
transition:.3s;z-index:999;pointer-events:none;box-shadow:0 8px 32px #000a}
#toast.show{transform:translateY(0);opacity:1}
#toast.ok{border-color:var(--green);color:var(--green)}
#toast.err{border-color:var(--red);color:var(--red)}
/* ── MODAL ── */
.modal-bg{position:fixed;inset:0;background:#0009;z-index:200;
display:flex;align-items:center;justify-content:center;padding:16px}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
width:100%;max-width:540px;max-height:90vh;overflow-y:auto}
.modal-head{padding:14px 16px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between}
.modal-body{padding:16px}
.modal-foot{padding:12px 16px;border-top:1px solid var(--border);
display:flex;gap:8px;justify-content:flex-end}
.modal-close{cursor:pointer;color:var(--muted);font-size:18px;line-height:1}
/* ── MISC ── */
.hidden{display:none!important}
.flex{display:flex}.gap8{gap:8px}.gap6{gap:6px}.items-center{align-items:center}
.flex-1{flex:1}.flex-wrap{flex-wrap:wrap}.justify-between{justify-content:space-between}
.text-muted{color:var(--muted)}.text-sm{font-size:11px}.mt8{margin-top:8px}.mt12{margin-top:12px}
.text-purple{color:var(--purple)}.text-green{color:var(--green)}.text-red{color:var(--red)}
.text-cyan{color:var(--cyan)}.text-yellow{color:var(--yellow)}
.separator{height:1px;background:var(--border);margin:12px 0}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.spin{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.section-hidden{display:none}
</style>
</head>
<body>
<div id="app">
<!-- TOP BAR -->
<div id="topbar">
<div>
<div class="logo">⚡ SecFERRO Manager</div>
<div class="sub">TVRemote · Termux · localhost:8080</div>
</div>
<div class="spacer"></div>
<div id="run-indicator" class="hidden text-yellow text-sm">▶ Trwa zadanie...</div>
<div class="status-dot" id="status-dot" title="Status projektu"></div>
</div>
<!-- NAV -->
<nav id="nav">
<button class="nav-btn active" onclick="showSection('dashboard')">Dashboard</button>
<button class="nav-btn" onclick="showSection('phases')">Fazy</button>
<button class="nav-btn" onclick="showSection('files')">Pliki</button>
<button class="nav-btn" onclick="showSection('logs')">Logi</button>
<button class="nav-btn" onclick="showSection('compose')">Composer</button>
<button class="nav-btn" onclick="showSection('terminal')">Terminal</button>
<button class="nav-btn" onclick="showSection('backups')">Backup</button>
</nav>
<!-- CONTENT -->
<div id="content">
<!-- ════ DASHBOARD ════ -->
<div id="sec-dashboard">
<div class="card">
<div class="card-title">Status projektu</div>
<div class="card-grid" id="stat-grid">
<div class="stat"><div class="val" id="st-phases">—</div><div class="lbl">Fazy łącznie</div></div>
<div class="stat"><div class="val text-green" id="st-applied">—</div><div class="lbl">Zastosowane</div></div>
<div class="stat"><div class="val text-cyan" id="st-files">—</div><div class="lbl">Pliki .kt</div></div>
<div class="stat"><div class="val text-yellow" id="st-size">—</div><div class="lbl">Rozmiar</div></div>
<div class="stat"><div class="val" id="st-backups">—</div><div class="lbl">Backupy</div></div>
<div class="stat"><div class="val text-purple" id="st-logs">—</div><div class="lbl">Pliki logów</div></div>
</div>
</div>
<div class="card">
<div class="card-title">Postęp faz</div>
<div class="flex items-center gap8 mt8" style="margin-bottom:6px">
<div class="text-sm text-muted">Zastosowane:</div>
<div class="text-sm text-purple" id="phase-progress-label">0 / 0</div>
</div>
<div class="progress-wrap">
<div class="progress-bar" id="phase-progress-bar" style="width:0%"></div>
</div>
<div class="mt8" id="applied-tags"></div>
</div>
<div class="card">
<div class="card-title">Ostatnia akcja</div>
<div id="last-action" class="text-muted text-sm">Brak</div>
</div>
<div class="card">
<div class="card-title">Szybkie akcje</div>
<div class="flex gap8 flex-wrap">
<button class="btn btn-primary" onclick="applyAllPending()">▶ Zastosuj brakujące fazy</button>
<button class="btn btn-outline" onclick="createBackup()">💾 Utwórz backup</button>
<button class="btn btn-outline" onclick="showSection('logs')">📋 Pokaż logi</button>
<button class="btn btn-danger" onclick="confirmWipe()">🗑 Wyczyść projekt</button>
</div>
</div>
<div class="card">
<div class="card-title">Środowisko</div>
<div id="env-info" class="text-sm text-muted">Ładowanie...</div>
</div>
</div>
<!-- ════ PHASES ════ -->
<div id="sec-phases" class="section-hidden">
<div class="card">
<div class="card-title">Menedżer faz aktualizacji</div>
<div class="flex gap8 flex-wrap" style="margin-bottom:12px">
<button class="btn btn-primary btn-sm" onclick="applyAllPending()">▶ Zastosuj wszystkie brakujące</button>
<button class="btn btn-outline btn-sm" onclick="loadPhases()">↻ Odśwież</button>
</div>
<div id="phases-list">Ładowanie...</div>
</div>
</div>
<!-- ════ FILES ════ -->
<div id="sec-files" class="section-hidden">
<div class="card">
<div class="card-title">Przeglądarka plików projektu</div>
<div class="breadcrumb" id="breadcrumb">
<span onclick="loadFiles('')">TVRemoteApp</span>
</div>
<div id="files-list">Ładowanie...</div>
</div>
<!-- Editor -->
<div id="file-editor-card" class="card hidden">
<div class="flex items-center justify-between" style="margin-bottom:10px">
<div class="card-title" style="margin:0" id="editor-title">Plik</div>
<div class="flex gap6">
<button class="btn btn-success btn-sm" onclick="saveFile()">💾 Zapisz</button>
<button class="btn btn-outline btn-sm" onclick="closeEditor()">✕ Zamknij</button>
</div>
</div>
<textarea id="code-editor" spellcheck="false"></textarea>
</div>
</div>
<!-- ════ LOGS ════ -->
<div id="sec-logs" class="section-hidden">
<div class="card">
<div class="card-title">Strumień logów (real-time SSE)</div>
<div class="flex gap6" style="margin-bottom:10px">
<button class="btn btn-outline btn-sm" onclick="clearLogs()">🗑 Wyczyść widok</button>
<button class="btn btn-outline btn-sm" id="autoscroll-btn" onclick="toggleAutoscroll()">⬇ Auto-scroll: ON</button>
<button class="btn btn-outline btn-sm" onclick="loadLogFiles()">📁 Pliki logów</button>
</div>
<div id="log-container"></div>
</div>
<div id="log-files-card" class="card hidden">
<div class="card-title">Zapisane pliki logów</div>
<div id="log-files-list">Ładowanie...</div>
</div>
</div>
<!-- ════ COMPOSER ════ -->
<div id="sec-compose" class="section-hidden">
<div class="card">
<div class="card-title">Composer — tworzenie nowych aktualizacji</div>
<div class="form-row">
<label class="form-label">Nazwa skryptu (np. update_9.py)</label>
<input class="form-input" id="comp-name" placeholder="update_9" />
</div>
<div class="form-row">
<label class="form-label">Opis aktualizacji</label>
<input class="form-input" id="comp-desc" placeholder="Opis co ta faza dodaje..." />
</div>
<div class="separator"></div>
<div class="card-title">Pliki do dodania</div>
<div id="comp-files-container"></div>
<div class="flex gap6 mt8">
<button class="btn btn-outline btn-sm" onclick="addComposeFile()">+ Dodaj plik</button>
</div>
<div class="separator"></div>
<div class="flex gap8">
<button class="btn btn-primary" onclick="composeUpdate()">⚡ Utwórz skrypt</button>
<button class="btn btn-outline" onclick="previewCompose()">👁 Podgląd</button>
</div>
<div id="compose-result" class="mt12 hidden"></div>
</div>
</div>
<!-- ════ TERMINAL ════ -->
<div id="sec-terminal" class="section-hidden">
<div class="card">
<div class="card-title">Terminal (ograniczony — bezpieczne komendy)</div>
<div id="terminal-output">$ Witaj w TVRemote Manager Terminal
$ Dozwolone komendy: ls, cat, python, pip, find, grep, adb, du, df...
</div>
<div class="flex gap6 mt8">
<span style="color:var(--purple);line-height:32px">$</span>
<input class="form-input flex-1" id="terminal-input"
placeholder="np. ls TVRemoteApp/ lub python update_1z6.py"
onkeydown="if(event.key==='Enter')runShell()"/>
<button class="btn btn-primary" onclick="runShell()">↵</button>
</div>
<div class="mt8 text-sm text-muted" id="term-history-hint"></div>
</div>
<div class="card">
<div class="card-title">Szybkie komendy</div>
<div class="flex gap6 flex-wrap">
<button class="btn btn-outline btn-sm" onclick="quickCmd('ls -la')">ls -la</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('python update_0.py')">dry-run update_0</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('du -sh TVRemoteApp/')">rozmiar projektu</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('find TVRemoteApp -name \'*.kt\' | wc -l')">liczba .kt</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('adb devices')">adb devices</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('pip list')">pip list</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('uname -a')">uname -a</button>
<button class="btn btn-outline btn-sm" onclick="quickCmd('df -h')">df -h</button>
</div>
</div>
</div>
<!-- ════ BACKUPS ════ -->
<div id="sec-backups" class="section-hidden">
<div class="card">
<div class="card-title">Zarządzanie kopiami zapasowymi</div>
<div class="flex gap8 flex-wrap" style="margin-bottom:12px">
<button class="btn btn-primary" onclick="showBackupModal()">💾 Nowy backup</button>
<button class="btn btn-outline" onclick="loadBackups()">↻ Odśwież</button>
</div>
<div id="backups-list">Ładowanie...</div>
</div>
</div>
</div><!-- /content -->
</div><!-- /app -->
<!-- TOAST -->
<div id="toast"></div>
<!-- MODAL: Diff Viewer -->
<div id="modal-diff" class="modal-bg hidden">
<div class="modal">
<div class="modal-head">
<span class="text-purple" id="diff-modal-title">Diff — pliki fazy</span>
<span class="modal-close" onclick="closeModal('modal-diff')">✕</span>
</div>
<div class="modal-body" id="diff-modal-body">Ładowanie...</div>
<div class="modal-foot"><button class="btn btn-outline" onclick="closeModal('modal-diff')">Zamknij</button></div>
</div>
</div>
<!-- MODAL: Backup Label -->
<div id="modal-backup" class="modal-bg hidden">
<div class="modal">
<div class="modal-head">
<span class="text-purple">Utwórz backup</span>
<span class="modal-close" onclick="closeModal('modal-backup')">✕</span>
</div>
<div class="modal-body">
<div class="form-row">
<label class="form-label">Etykieta (opcjonalna)</label>
<input class="form-input" id="backup-label" placeholder="np. przed_fazą_8" />
</div>
</div>
<div class="modal-foot">
<button class="btn btn-outline" onclick="closeModal('modal-backup')">Anuluj</button>
<button class="btn btn-primary" onclick="createBackupConfirm()">💾 Zapisz</button>
</div>
</div>
</div>
<!-- MODAL: Restore Confirm -->
<div id="modal-restore" class="modal-bg hidden">
<div class="modal">
<div class="modal-head">
<span class="text-yellow">Przywróć backup</span>
<span class="modal-close" onclick="closeModal('modal-restore')">✕</span>
</div>
<div class="modal-body">
<p class="text-muted text-sm">Czy na pewno chcesz przywrócić backup:</p>
<p class="text-purple mt8" id="restore-name-label"></p>
<p class="text-sm text-red mt8">⚠️ Bieżący stan projektu zostanie NADPISANY.</p>
</div>
<div class="modal-foot">
<button class="btn btn-outline" onclick="closeModal('modal-restore')">Anuluj</button>
<button class="btn btn-danger" onclick="restoreBackupConfirm()">♻️ Przywróć</button>
</div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════════════
// STATE
// ════════════════════════════════════════════════════════════
let currentSection = 'dashboard';
let autoscroll = true;
let sseSource = null;
let currentFilePath= '';
let pendingRestore = '';
let termHistory = [];
let termIdx = -1;
// ════════════════════════════════════════════════════════════
// NAVIGATION
// ════════════════════════════════════════════════════════════
function showSection(name) {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('[id^="sec-"]').forEach(s => s.classList.add('section-hidden'));
document.getElementById('sec-' + name).classList.remove('section-hidden');
currentSection = name;
// Aktywuj przycisk nawigacji
document.querySelectorAll('.nav-btn').forEach(b => {
if (b.textContent.toLowerCase().includes(name.substring(0,4).toLowerCase()))
b.classList.add('active');
});
// Lazy load
if (name === 'phases') loadPhases();
if (name === 'files') loadFiles('');
if (name === 'logs') startSSE();
if (name === 'backups') loadBackups();
}
// ════════════════════════════════════════════════════════════
// API HELPER
// ════════════════════════════════════════════════════════════
async function api(url, method='GET', body=null) {
const opts = {method, headers:{'Content-Type':'application/json'}};
if (body) opts.body = JSON.stringify(body);
try {
const r = await fetch(url, opts);
return await r.json();
} catch(e) {
toast('Błąd sieci: ' + e.message, 'err');
return {ok: false, error: e.message};
}
}
// ════════════════════════════════════════════════════════════
// DASHBOARD
// ════════════════════════════════════════════════════════════
async function loadDashboard() {
const s = await api('/api/status');
if (!s) return;
document.getElementById('st-phases').textContent = s.phases_total;
document.getElementById('st-applied').textContent = s.phases_applied;
document.getElementById('st-files').textContent = s.kt_files;
document.getElementById('st-size').textContent = s.total_size;
document.getElementById('st-backups').textContent = s.backups_count;
document.getElementById('st-logs').textContent = s.log_count;
const pct = s.phases_total ? Math.round(s.phases_applied/s.phases_total*100) : 0;
document.getElementById('phase-progress-bar').style.width = pct+'%';
document.getElementById('phase-progress-label').textContent = `${s.phases_applied} / ${s.phases_total}`;
// Applied tags
const tagsEl = document.getElementById('applied-tags');
tagsEl.innerHTML = (s.applied_names||[]).map(n=>
`<span style="display:inline-block;margin:2px 3px;padding:2px 7px;font-size:9px;
background:rgba(16,185,129,.12);color:var(--green);border-radius:3px;
border:1px solid rgba(16,185,129,.25)">${n}</span>`
).join('');
// Last action
const la = s.last_action;
document.getElementById('last-action').innerHTML = la
? `<span class="text-purple">${la.type}</span> · <span class="text-cyan">${la.script||''}</span>
· <span class="text-muted">${la.ts?.substring(0,19)?.replace('T',' ')}</span>`
: '<span class="text-muted">Brak</span>';
// Env
document.getElementById('env-info').innerHTML =
`Python: <span class="text-cyan">${s.python}</span> &nbsp;·&nbsp;
Termux: <span class="${s.termux?'text-green':'text-muted'}">${s.termux?'Tak':'Nie'}</span> &nbsp;·&nbsp;
update_0.py: <span class="${s.has_update0?'text-green':'text-red'}">${s.has_update0?'✅ '+s.update0_size:'❌ Brak'}</span> &nbsp;·&nbsp;
Projekt: <span class="${s.project_exists?'text-green':'text-muted'}">${s.project_exists?'✅ Istnieje':'⚠️ Brak'}</span>`;
// Status dot
const dot = document.getElementById('status-dot');
dot.className = 'status-dot ' + (s.running ? 'running' : s.project_exists ? 'connected' : '');
document.getElementById('run-indicator').classList.toggle('hidden', !s.running);
}
// ════════════════════════════════════════════════════════════
// PHASES
// ════════════════════════════════════════════════════════════
async function loadPhases() {
const d = await api('/api/phases');
const container = document.getElementById('phases-list');
if (!d.ok) { container.innerHTML = '<span class="text-red">Błąd ładowania</span>'; return; }
if (!d.phases.length) {
container.innerHTML = '<span class="text-muted">Brak skryptów update_*.py w katalogu</span>'; return;
}
container.innerHTML = d.phases.map(p => `
<div class="phase-card ${p.applied?'applied':''}">
<div class="ph-head">
<span class="ph-tag">${p.tag}</span>
<span class="ph-desc">${p.desc}</span>
<span class="ph-badge ${p.applied?'applied':'pending'}">${p.applied?'✓ OK':'CZEKA'}</span>
</div>
<div class="ph-meta">
<span>📄 ${p.files} plików</span>
<span>📦 ${Math.round(p.size/1024)}KB</span>
<span>🕐 ${p.mtime}</span>
</div>
<div class="ph-actions">
<button class="btn btn-primary btn-sm" onclick="runPhase('${p.name}',false)">▶ Zastosuj</button>
<button class="btn btn-outline btn-sm" onclick="runPhase('${p.name}',true)">👁 Dry-run</button>
<button class="btn btn-cyan btn-sm" onclick="showDiff('${p.name}')">± Diff</button>
<button class="btn btn-sm" style="background:rgba(100,116,139,.1);color:var(--muted)"
onclick="toggleMark('${p.name}',${p.applied})">${p.applied?'✗ Odznacz':'✓ Oznacz'}</button>
</div>
</div>`).join('');
}
async function runPhase(name, dryRun) {
const label = dryRun ? 'dry-run' : 'zastosowanie';
const r = await api(`/api/phases/${encodeURIComponent(name)}/run`, 'POST', {dry_run: dryRun});
if (r.ok) {
toast(`▶ ${label}: ${name}`, 'ok');
showSection('logs');
setTimeout(() => { loadPhases(); loadDashboard(); }, 2000);
} else toast(r.error || 'Błąd', 'err');
}
async function toggleMark(name, current) {
await api(`/api/phases/${encodeURIComponent(name)}/mark`, 'POST', {applied: !current});
loadPhases(); loadDashboard();
}
async function applyAllPending() {
const d = await api('/api/phases');
const pending = (d.phases||[]).filter(p => !p.applied);
if (!pending.length) { toast('Wszystkie fazy zastosowane ✅', 'ok'); return; }
toast(`Uruchamiam ${pending.length} faz...`, 'ok');
showSection('logs');
for (const p of pending) {
await api(`/api/phases/${encodeURIComponent(p.name)}/run`, 'POST', {dry_run: false});
await new Promise(r => setTimeout(r, 3500)); // Czekaj na zakończenie
}
setTimeout(() => { loadPhases(); loadDashboard(); }, 1000);
}
async function showDiff(name) {
document.getElementById('diff-modal-title').textContent = `Diff — ${name}`;
document.getElementById('diff-modal-body').innerHTML = 'Ładowanie...';
document.getElementById('modal-diff').classList.remove('hidden');
const d = await api(`/api/phases/${encodeURIComponent(name)}/diff`);
if (!d.ok) {
document.getElementById('diff-modal-body').innerHTML = `<span class="text-red">${d.error}</span>`;
return;
}
document.getElementById('diff-modal-body').innerHTML =
`<div class="text-sm text-muted" style="margin-bottom:8px">Łącznie: ${d.count} plików</div>` +
(d.files||[]).map(f=>`
<div class="diff-item ${f.status}">
<span class="diff-badge ${f.status}">${f.status==='new'?'NOWY':'UPDATE'}</span>
<span class="text-sm" style="word-break:break-all">${f.path}</span>
</div>`).join('');
}
// ════════════════════════════════════════════════════════════
// FILES
// ════════════════════════════════════════════════════════════
async function loadFiles(path) {
currentFilePath = path;
const d = await api('/api/files?path=' + encodeURIComponent(path));
const container = document.getElementById('files-list');
// Breadcrumb
const parts = path ? path.split('/') : [];
let bc = `<span onclick="loadFiles('')">TVRemoteApp</span>`;
let acc = '';
parts.forEach((part,i) => {
acc += (i?'/':'') + part;
const p = acc;
bc += ` <span style="color:var(--muted)">›</span> <span onclick="loadFiles('${p}')">${part}</span>`;
});
document.getElementById('breadcrumb').innerHTML = bc;
if (!d.ok) { container.innerHTML = `<span class="text-red">${d.error}</span>`; return; }
const EXT_ICONS = {kt:'🔷',xml:'📄',py:'🐍',md:'📝',gradle:'🐘',json:'🔧',
properties:'⚙️',pro:'🛡️',kts:'🐘'};
container.innerHTML = d.items.map(item => {
const icon = item.is_dir ? '📁' : (EXT_ICONS[item.ext] || '📄');
return `<div class="file-item" onclick="${item.is_dir ? `loadFiles('${item.path}')` : `openFile('${item.path}')`}">
<span class="fi-icon">${icon}</span>
<span class="fi-name">${item.name}</span>
<span class="fi-meta">${item.size} ${item.mtime}</span>
</div>`;
}).join('') || '<span class="text-muted">Pusty katalog</span>';
}
async function openFile(path) {
const d = await api('/api/files/read?path=' + encodeURIComponent(path));
if (!d.ok) { toast(d.error, 'err'); return; }
document.getElementById('editor-title').textContent = path.split('/').pop() + ' · ' + d.size;
document.getElementById('code-editor').value = d.content;
document.getElementById('code-editor').dataset.path = path;
document.getElementById('file-editor-card').classList.remove('hidden');
document.getElementById('file-editor-card').scrollIntoView({behavior:'smooth'});
}
async function saveFile() {
const el = document.getElementById('code-editor');
const path = el.dataset.path;
const content = el.value;
const r = await api('/api/files/save', 'POST', {path, content});
toast(r.ok ? '💾 Zapisano: ' + path : (r.error||'Błąd'), r.ok?'ok':'err');
}
function closeEditor() {
document.getElementById('file-editor-card').classList.add('hidden');
}
// ════════════════════════════════════════════════════════════
// LOGS (SSE)
// ════════════════════════════════════════════════════════════
function startSSE() {
if (sseSource) return;
sseSource = new EventSource('/api/logs/stream');
sseSource.onmessage = e => {
try {
const d = JSON.parse(e.data);
if (d.level === 'PING') return;
appendLog(d);
} catch {}
};
sseSource.onerror = () => {
setTimeout(() => { sseSource = null; if(currentSection==='logs') startSSE(); }, 3000);
};
}
function appendLog(d) {
const el = document.getElementById('log-container');
const div = document.createElement('div');
div.className = `log-line ${d.level}`;
div.innerHTML = `<span class="log-ts">${d.ts}</span><span class="log-icon">${d.icon}</span><span class="log-msg">${escHtml(d.msg)}</span>`;
el.appendChild(div);
if (el.children.length > 500) el.removeChild(el.firstChild);
if (autoscroll) el.scrollTop = el.scrollHeight;
}
function clearLogs() {
document.getElementById('log-container').innerHTML = '';
}
function toggleAutoscroll() {
autoscroll = !autoscroll;
document.getElementById('autoscroll-btn').textContent = `⬇ Auto-scroll: ${autoscroll?'ON':'OFF'}`;
}
async function loadLogFiles() {
document.getElementById('log-files-card').classList.remove('hidden');
const d = await api('/api/logs/files');
const container = document.getElementById('log-files-list');
if (!d.ok || !d.files.length) {
container.innerHTML = '<span class="text-muted">Brak plików logów</span>'; return;
}
container.innerHTML = d.files.map(f=>
`<div class="file-item"><span class="fi-icon">📋</span>
<span class="fi-name">${f.name}</span>
<span class="fi-meta">${f.size} · ${f.mtime}</span></div>`
).join('');
}
// ════════════════════════════════════════════════════════════
// TERMINAL
// ════════════════════════════════════════════════════════════
async function runShell() {
const input = document.getElementById('terminal-input');
const cmd = input.value.trim();
if (!cmd) return;
termHistory.unshift(cmd); if(termHistory.length>50) termHistory.pop();
termIdx = -1;
input.value = '';
const out = document.getElementById('terminal-output');
out.textContent += `$ ${cmd}\n`;
const r = await api('/api/shell', 'POST', {cmd});
out.textContent += (r.ok ? r.output : `❌ ${r.error}`) + '\n';
out.scrollTop = out.scrollHeight;
}
function quickCmd(cmd) {
document.getElementById('terminal-input').value = cmd;
document.getElementById('terminal-input').focus();
}
// Historia klawiatury
document.addEventListener('keydown', e => {
if (currentSection !== 'terminal') return;
const inp = document.getElementById('terminal-input');
if (e.key === 'ArrowUp' && termHistory.length) {
termIdx = Math.min(termIdx+1, termHistory.length-1);
inp.value = termHistory[termIdx];
}
if (e.key === 'ArrowDown') {
termIdx = Math.max(termIdx-1, -1);
inp.value = termIdx < 0 ? '' : termHistory[termIdx];
}
});
// ════════════════════════════════════════════════════════════
// COMPOSER
// ════════════════════════════════════════════════════════════
let composeFileCount = 0;
function addComposeFile() {
composeFileCount++;
const id = composeFileCount;
const container = document.getElementById('comp-files-container');
const div = document.createElement('div');
div.id = `comp-file-${id}`;
div.style.marginBottom = '12px';
div.innerHTML = `
<div class="flex items-center gap6" style="margin-bottom:4px">
<input class="form-input flex-1" id="comp-fpath-${id}"
placeholder="app/src/main/java/.../MojPlik.kt" />
<button class="btn btn-danger btn-sm" onclick="document.getElementById('comp-file-${id}').remove()">✕</button>
</div>
<textarea class="form-input form-textarea" id="comp-fcontent-${id}"
placeholder="// Zawartość pliku Kotlin/XML..."></textarea>`;
container.appendChild(div);
}
async function composeUpdate() {
const name = document.getElementById('comp-name').value.trim();
const desc = document.getElementById('comp-desc').value.trim();
const files = [];
document.querySelectorAll('[id^="comp-fpath-"]').forEach(el => {
const id = el.id.replace('comp-fpath-','');
const path = el.value.trim();
const content = document.getElementById(`comp-fcontent-${id}`)?.value || '';
if (path) files.push({path, content});
});
const r = await api('/api/compose', 'POST', {name, description: desc, files});
const res = document.getElementById('compose-result');
res.classList.remove('hidden');
res.innerHTML = r.ok
? `<div class="text-green">✅ Utworzono: <span class="text-cyan">${r.name}</span></div>
<div class="text-sm text-muted mt8">Uruchom: <code>python ${r.name} --finish</code></div>
<div class="flex gap6 mt8">
<button class="btn btn-primary btn-sm" onclick="runPhase('${r.name}',false)">▶ Zastosuj teraz</button>
<button class="btn btn-outline btn-sm" onclick="loadPhases();showSection('phases')">📋 Pokaż fazy</button>
</div>`
: `<div class="text-red">❌ ${r.error}</div>`;
if (r.ok) { toast('✅ Skrypt utworzony: ' + r.name, 'ok'); }
}
function previewCompose() {
toast('Podgląd: sprawdź sekcję Fazy po utworzeniu skryptu', 'ok');
}
// ════════════════════════════════════════════════════════════
// BACKUPS
// ════════════════════════════════════════════════════════════
async function loadBackups() {
const d = await api('/api/backup/list');
const container = document.getElementById('backups-list');
if (!d.backups?.length) {
container.innerHTML = '<span class="text-muted">Brak backupów</span>'; return;
}
container.innerHTML = [...d.backups].reverse().map(b => `
<div class="backup-item">
<div class="backup-info">
<div class="backup-name">💾 ${b.name}</div>
<div class="backup-meta">
${b.label ? `<span class="text-cyan">${b.label}</span> · ` : ''}
${Math.round((b.size||0)/1024/1024*10)/10} MB ·
${(b.phases||[]).length} faz
</div>
</div>
<div class="flex gap6">
<button class="btn btn-outline btn-sm" onclick="showRestoreModal('${b.name}')">♻️</button>
<button class="btn btn-danger btn-sm" onclick="deleteBackup('${b.name}')">🗑</button>
</div>
</div>`).join('');
}
function showBackupModal() { document.getElementById('modal-backup').classList.remove('hidden'); }
async function createBackupConfirm() {
const label = document.getElementById('backup-label').value.trim();
closeModal('modal-backup');
document.getElementById('backup-label').value = '';
await createBackup(label);
}
async function createBackup(label='') {
toast('💾 Tworzenie backupu...', 'ok');
const r = await api('/api/backup/create', 'POST', {label});
toast(r.ok ? `✅ Backup: ${r.name} (${r.size})` : (r.error||'Błąd'), r.ok?'ok':'err');
if (r.ok) loadBackups();
}
function showRestoreModal(name) {
pendingRestore = name;
document.getElementById('restore-name-label').textContent = name;
document.getElementById('modal-restore').classList.remove('hidden');
}
async function restoreBackupConfirm() {
closeModal('modal-restore');
toast('♻️ Przywracanie...', 'ok');
const r = await api('/api/backup/restore', 'POST', {name: pendingRestore});
toast(r.ok ? '✅ Backup przywrócony!' : (r.error||'Błąd'), r.ok?'ok':'err');
if (r.ok) { loadDashboard(); loadBackups(); }
}
async function deleteBackup(name) {
if (!confirm('Usunąć backup: ' + name + '?')) return;
const r = await api('/api/backup/delete', 'POST', {name});
toast(r.ok ? '🗑 Usunięto' : (r.error||'Błąd'), r.ok?'ok':'err');
if (r.ok) loadBackups();
}
// ════════════════════════════════════════════════════════════
// MISC
// ════════════════════════════════════════════════════════════
async function confirmWipe() {
if (!confirm('⚠️ UWAGA!\nTo usunie cały folder TVRemoteApp/.\nCzy masz backup?\n\nWpisz OK aby potwierdzić.')) return;
const r = await api('/api/project/wipe', 'POST', {confirm: true});
toast(r.ok ? '🗑 Projekt usunięty' : (r.error||'Błąd'), r.ok?'ok':'err');
if (r.ok) loadDashboard();
}
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
let _toastTimer;
function toast(msg, type='ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `show ${type}`;
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.className = '', 3500);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ════════════════════════════════════════════════════════════
// INIT + AUTO-REFRESH
// ════════════════════════════════════════════════════════════
loadDashboard();
startSSE();
// Odśwież dashboard co 8 sekund
setInterval(() => { if(currentSection==='dashboard') loadDashboard(); }, 8000);
// Odśwież fazy co 5s gdy trwa zadanie
setInterval(async () => {
const s = await api('/api/status');
if (s?.running && currentSection==='phases') loadPhases();
}, 5000);
</script>
</body>
</html>"""
@app.route("/")
def index():
return HTML, 200, {"Content-Type": "text/html; charset=utf-8"}
# ════════════════════════════════════════════════════════════════════════
# PUNKT WEJŚCIA
# ════════════════════════════════════════════════════════════════════════
def _print_banner() -> None:
print("\n" + "═" * 62)
print(" ⚡ SecFERRO Division · TVRemote Project Manager")
print(" 📱 Środowisko: Termux / Android aarch64")
print("─" * 62)
print(f" 🌐 http://localhost:{PORT}")
print(f" 📁 Projekt: {PROJECT_ROOT}")
print(f" 💾 Backupy: {BACKUP_DIR}")
print(f" 📋 Logi: {LOG_DIR}")
print("─" * 62)
print(" Ctrl+C aby zatrzymać serwer")
print("═" * 62 + "\n")
if __name__ == "__main__":
_print_banner()
_log("🚀 SecFERRO TVRemote Manager uruchomiony", "SYS")
_log(f" Nasłuchuję na http://localhost:{PORT}", "SYS")
# Sprawdź dostępność Flaska
try:
import flask
_log(f" Flask {flask.__version__} ✅", "SYS")
except ImportError:
pass
app.run(
host = "0.0.0.0",
port = PORT,
debug = False,
threaded= True, # Wymagane dla SSE + równoległych requestów
use_reloader = False, # Wyłącz reloader — psuje SSE w Termux
)

Instrukcja Obsługi i Operacji (Usage Guide)

Dokument ten opisuje mechaniki zarządzania projektem z wykorzystaniem środowiska SecFERRO DevOps Center.

1. Wywołanie CLI i Nasłuch Sieciowy

Skrypt project_manager.py posiada wbudowany parser argumentów pozwalający na precyzyjne ustawienie parametrów sieciowych.

Komenda Opis Poziom Bezpieczeństwa
python project_manager.py Domyślne wywołanie (Port 8080, Host 127.0.0.1). 🟢 Maksymalny (Tylko lokalnie)
python project_manager.py --port 9090 Zmiana portu nasłuchu na niestandardowy. 🟢 Maksymalny
python project_manager.py --host 0.0.0.0 LAN Mode. Otwiera panel dla całej sieci (np. sterowanie z PC). 🔴 Ostrzegawczy (Patrz SECURITY.md)

2. Nawigacja w Web Dashboardzie (SPA)

Po wejściu do panelu (http://127.0.0.1:8080), po lewej stronie (lub w menu bocznym) znajdziesz główne moduły:

  1. Dashboard (📊): Centralny punkt informacyjny. Wyświetla statystyki plików (np. ilość kodu .kt), postęp faz, szybkie akcje wstrzykiwania update_0.py oraz sekcję notatek (zapisywaną persistentnie).
  2. Fazy (⚡): Kafelkowy widok infrastruktury iniekcyjnej. Każda faza (od 1 do 8) posiada weryfikator obecności skryptu na dysku, zależności od innych faz oraz przyciski ▶ Wykonaj (zapis) i 🔍 Dry-run (podgląd).
  3. Terminal (💻): Aktywny strumień wydarzeń. Umożliwia śledzenie działania subprocesów, a także wyzwalanie własnych skryptów z argumentami poprzez formularz.
  4. Zadania (📋): Pełna historia jobów, ich czasy egzekucji i statusy wyjścia (exit_code). Możliwość podejrzenia historycznych logów.
  5. Wersja (🏷️) i Changelog (📝): Interfejs do semantycznego podbijania wersji (Major/Minor/Patch) z automatycznym dopisywaniem commitów do CHANGELOG.md.

3. Globalne Skróty Klawiszowe

Dla inżynierów operujących na fizycznej klawiaturze (np. iPad z Magic Keyboard lub PC na LAN):

  • R - Wymusza natychmiastowe odświeżenie całego stanu systemu (API /api/status).
  • T - Szybki skok do widoku Terminala Live (Live stream).
  • ESC - Powrót do głównego Dashboardu.

4. Resetowanie Środowiska (Hard Reset)

W zakładce Wersja (🏷️) na samym dole znajduje się obszar "Niebezpieczne". Znajdziesz tam mechanizmy wymazywania logów, atomowego usunięcia stanu .pm_state.json oraz bezpowrotnego usunięcia całego katalogu wygenerowanego kodu (TVRemoteApp/).

@anonymousik

Copy link
Copy Markdown
Author

| ⤵️ | TERMUX_SERVER | ⤵️ |
1000351257

| ⤵️ | WEBGUI_SYSTEM | ⤵️ |
1000351258

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment