|
#!/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 - 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,'&').replace(/</g,'<').replace(/>/g,'>'); |
|
} |
|
|
|
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() |
| ⤵️ | TERMUX_SERVER | ⤵️ || ⤵️ | WEBGUI_SYSTEM | ⤵️ |