From 761ad4de6110342fdb32574571dec29dac3ca311 Mon Sep 17 00:00:00 2001 From: lvsi Date: Wed, 20 May 2026 15:54:06 +0800 Subject: [PATCH 1/7] feat: change server.py of web path --- .../{include/web/server.py => web/index.html} | 437 +---------------- .work/web/server.py | 444 ++++++++++++++++++ sparrow | 2 +- 3 files changed, 446 insertions(+), 437 deletions(-) rename .work/{include/web/server.py => web/index.html} (59%) create mode 100644 .work/web/server.py diff --git a/.work/include/web/server.py b/.work/web/index.html similarity index 59% rename from .work/include/web/server.py rename to .work/web/index.html index b322b56..09f3d71 100644 --- a/.work/include/web/server.py +++ b/.work/web/index.html @@ -1,243 +1,4 @@ -#!/usr/bin/env python3 -""" -Sparrow Web Dashboard -Usage: python3 server.py [port] -""" - -import http.server -import json -import os -import platform -import re -import subprocess -import sys -import threading -import webbrowser -from urllib.parse import urlparse - -BASE_PATH = os.environ.get("SPARROW_BASE_PATH", os.getcwd()) - - -def _parse_root_env(): - """Read and parse root .env file into a dict.""" - root_env = os.path.join(BASE_PATH, ".env") - result = {} - if os.path.isfile(root_env): - with open(root_env, "r", encoding="utf-8") as f: - for line in f: - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if "=" in line: - k, _, v = line.partition("=") - result[k.strip()] = v.strip() - return result - - -def _load_enable_service_list(): - """Read ENABLE_SERVICE_LIST from root .env file.""" - env = _parse_root_env() - if "ENABLE_SERVICE_LIST" in env: - val = env["ENABLE_SERVICE_LIST"].strip() - if val.startswith("(") and val.endswith(")"): - val = val[1:-1] - return re.findall(r'"([^"]+)"', val) - return [] - - -def _load_support_service_list(): - """Read SUPPORT_SERVICE_LIST from root .env file.""" - env = _parse_root_env() - if "SUPPORT_SERVICE_LIST" in env: - val = env["SUPPORT_SERVICE_LIST"].strip() - if val.startswith("(") and val.endswith(")"): - val = val[1:-1] - return re.findall(r'"([^"]+)"', val) - return [] - - -def parse_config_file(): - """Read CONTAINER_NAMESPACE from root .env.""" - return _parse_root_env() - - -def get_running_containers(): - """Return a set of running container names.""" - try: - r = subprocess.run( - ["docker", "ps", "--format", "{{.Names}}"], - capture_output=True, text=True, timeout=5 - ) - return set(r.stdout.strip().splitlines()) - except Exception: - return set() - - -def get_local_images(): - """Return a set of 'repo:tag' strings of locally available images.""" - try: - r = subprocess.run( - ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], - capture_output=True, text=True, timeout=5 - ) - return set(r.stdout.strip().splitlines()) - except Exception: - return set() - - -def parse_env_file(service): - """Parse per-service variables from the root .env file.""" - root_env = os.path.join(BASE_PATH, ".env") - service_upper = service.upper() - images = [] - config = [] - if not os.path.isfile(root_env): - return images, config - - # We collect variables that belong to this service: - # 1. Variables prefixed with SERVICE_UPPER_ - # 2. Variables prefixed with IMAGE_*_SERVICE_UPPER_* - prefix = service_upper + "_" - with open(root_env, "r", encoding="utf-8") as f: - for raw in f: - line = raw.rstrip("\n") - stripped = line.strip() - if not stripped or stripped.startswith("###") or stripped.startswith("#"): - continue - if "=" not in line: - continue - key_part, _, rest = line.partition("=") - key = key_part.strip() - if "#" in rest: - value, _, comment = rest.partition("#") - value = value.strip() - comment = comment.strip() - else: - value = rest.strip() - comment = "" - - # Match image keys: IMAGE_(BASIC|APP|OFFICIAL)_{SERVICE}_... - is_image = bool(re.match( - r'^IMAGE_(BASIC|APP|OFFICIAL)_' + re.escape(service_upper) + r'(_|$)', key - )) - # Match config keys: {SERVICE}_... - is_config = key.startswith(prefix) - - if not is_image and not is_config: - continue - - entry = {"key": key, "value": value, "comment": comment} - if is_image: - images.append(entry) - else: - config.append(entry) - return images, config - - -def read_compose_file(service): - """Extract this service's block from the root docker-compose.yml.""" - root_compose = os.path.join(BASE_PATH, "docker-compose.yml") - if not os.path.isfile(root_compose): - return "" - - with open(root_compose, "r", encoding="utf-8") as f: - lines = f.readlines() - - # Find the line where this service's block starts (2-space indented key under services:) - # e.g. " etcd:" or " mysql:" - service_pattern = re.compile(r'^ ' + re.escape(service) + r'\s*:') - # Any other top-level service key (2-space indent + word + colon, but not deeper indent) - next_service_pattern = re.compile(r'^ [a-zA-Z]') - - in_services = False - start = None - end = None - - for i, line in enumerate(lines): - if line.strip() == "services:": - in_services = True - continue - if not in_services: - continue - if start is None: - if service_pattern.match(line): - start = i - else: - # Stop at the next sibling service key (same 2-space indent, different name) - if next_service_pattern.match(line) and not service_pattern.match(line): - end = i - break - - if start is None: - return "" - block = lines[start:end] if end else lines[start:] - # Strip trailing blank lines - while block and not block[-1].strip(): - block.pop() - return "".join(block) - - -def build_data(): - cfg = parse_config_file() - namespace = cfg.get("CONTAINER_NAMESPACE", "") - running = get_running_containers() - local_images = get_local_images() - enabled_services = _load_enable_service_list() - support_services = _load_support_service_list() - - services = [] - for svc in support_services: - container_name = f"sparrow_container_{namespace}_{svc}" if namespace else f"sparrow_container_{svc}" - is_running = container_name in running - images, config = parse_env_file(svc) - compose = read_compose_file(svc) - has_env = svc in enabled_services - - # Enrich image entries with local availability - for img in images: - key = img["key"] - val = img["value"] - # Try to detect if this is a version key and pair with name key - img["local"] = False - if key.endswith("_VERSION") and val: - # e.g. IMAGE_BASIC_MYSQL_VERSION=8.0 → sparrow-basic-mysql:8.0 - m = re.match(r'^IMAGE_(BASIC|APP|OFFICIAL)_(.+)_VERSION$', key) - if m: - kind = m.group(1).lower() - svc_name = m.group(2).lower() - if kind == "official": - # official image name comes from IMAGE_OFFICIAL_{SVC}_NAME - name_key = f"IMAGE_OFFICIAL_{m.group(2)}_NAME" - name_entry = next((e["value"] for e in images if e["key"] == name_key), svc_name) - repo = f"{name_entry}:{val}" - else: - repo = f"sparrow-{kind}-{svc_name}:{val}" - img["repo"] = repo - img["local"] = repo in local_images - - services.append({ - "name": svc, - "container": container_name, - "running": is_running, - "hasEnv": has_env, - "images": images, - "config": config, - "compose": compose, - }) - - installed = [s for s in services if s["hasEnv"]] - running_list = [s for s in services if s["running"]] - return { - "namespace": namespace, - "services": services, - "runningCount": len(running_list), - "installedCount": len(installed), - "totalCount": len(services), - "stoppedCount": len(installed) - len(running_list), - } - - -HTML_TEMPLATE = r""" + @@ -858,199 +619,3 @@ def build_data(): -""" - - -class Handler(http.server.BaseHTTPRequestHandler): - def log_message(self, format, *args): - pass # suppress access logs - - def do_GET(self): - path = urlparse(self.path).path - if path == "/api/data": - data = json.dumps(build_data(), ensure_ascii=False).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", len(data)) - self.end_headers() - self.wfile.write(data) - elif path == "/": - body = HTML_TEMPLATE.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", len(body)) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - - def do_POST(self): - path = urlparse(self.path).path - if path == "/api/action": - length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(length) - try: - req = json.loads(body) - service = req.get("service", "").strip() - action = req.get("action", "").strip() - allowed_actions = {"startone", "stopone", "restart"} - if not service or action not in allowed_actions or not re.match(r'^[a-zA-Z0-9_-]+$', service): - raise ValueError("invalid service or action") - sparrow = os.path.join(BASE_PATH, "sparrow") - label = f"[web] ./sparrow {action} {service}" - print(f"\n{'─'*60}") - print(f"▶ {label}") - print(f"{'─'*60}", flush=True) - proc = subprocess.Popen( - [sparrow, action, service], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, cwd=BASE_PATH - ) - output_lines = [] - for line in proc.stdout: - line_stripped = line.rstrip("\n") - print(line_stripped, flush=True) - output_lines.append(line_stripped) - proc.wait() - ok = proc.returncode == 0 - print(f"{'─'*60}") - print(f"{'✓' if ok else '✗'} {label} (exit {proc.returncode})") - print(f"{'─'*60}\n", flush=True) - output_text = "\n".join(output_lines[-100:]) - resp = json.dumps({ - "ok": ok, - "stdout": output_text, - "error": "" if ok else output_lines[-1] if output_lines else "exit code " + str(proc.returncode), - }, ensure_ascii=False).encode("utf-8") - except Exception as e: - resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", len(resp)) - self.end_headers() - self.wfile.write(resp) - elif path == "/api/disable": - length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(length) - try: - req = json.loads(body) - service = req.get("service", "").strip() - if not service or not re.match(r'^[a-zA-Z0-9_-]+$', service): - raise ValueError("invalid service") - - # Update only root .env file - root_env = os.path.join(BASE_PATH, ".env") - if os.path.isfile(root_env): - with open(root_env, "r", encoding="utf-8") as f: - lines = f.readlines() - - new_lines = [] - for line in lines: - if line.strip().startswith("ENABLE_SERVICE_LIST="): - # Extract the list content - list_str = line.split("=", 1)[1].strip() - if list_str.startswith("(") and list_str.endswith(")"): - list_content = list_str[1:-1] - # Parse quoted strings - items = re.findall(r'"([^"]+)"', list_content) - # Remove the service - new_items = [item for item in items if item != service] - # Rebuild the list - new_list_str = "(" + " ".join(f'"{item}"' for item in new_items) + ")" - new_line = re.sub(r'ENABLE_SERVICE_LIST\s*=\s*\([^)]*\)', f"ENABLE_SERVICE_LIST={new_list_str}", line) - new_lines.append(new_line) - else: - new_lines.append(line) - else: - new_lines.append(line) - - with open(root_env, "w", encoding="utf-8") as f: - f.writelines(new_lines) - - label = f"[web] disabled service {service}" - print(f"\n{'─'*60}") - print(f"✓ {label}") - print(f"{'─'*60}\n", flush=True) - resp = json.dumps({"ok": True}, ensure_ascii=False).encode("utf-8") - except Exception as e: - resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", len(resp)) - self.end_headers() - self.wfile.write(resp) - elif path == "/api/enable": - length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(length) - try: - req = json.loads(body) - service = req.get("service", "").strip() - if not service or not re.match(r'^[a-zA-Z0-9_-]+$', service): - raise ValueError("invalid service") - - # Update only root .env file - root_env = os.path.join(BASE_PATH, ".env") - if os.path.isfile(root_env): - with open(root_env, "r", encoding="utf-8") as f: - lines = f.readlines() - - new_lines = [] - for line in lines: - if line.strip().startswith("ENABLE_SERVICE_LIST="): - # Extract the list content - list_str = line.split("=", 1)[1].strip() - if list_str.startswith("(") and list_str.endswith(")"): - list_content = list_str[1:-1] - # Parse quoted strings - items = re.findall(r'"([^"]+)"', list_content) - # Add the service if not already present - if service not in items: - items.append(service) - # Rebuild the list - new_list_str = "(" + " ".join(f'"{item}"' for item in items) + ")" - new_line = re.sub(r'ENABLE_SERVICE_LIST\s*=\s*\([^)]*\)', f"ENABLE_SERVICE_LIST={new_list_str}", line) - new_lines.append(new_line) - else: - new_lines.append(line) - else: - new_lines.append(line) - - with open(root_env, "w", encoding="utf-8") as f: - f.writelines(new_lines) - - label = f"[web] enabled service {service}" - print(f"\n{'─'*60}") - print(f"✓ {label}") - print(f"{'─'*60}\n", flush=True) - resp = json.dumps({"ok": True}, ensure_ascii=False).encode("utf-8") - except Exception as e: - resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", len(resp)) - self.end_headers() - self.wfile.write(resp) - else: - self.send_response(404) - self.end_headers() - - -def main(): - port = int(sys.argv[1]) if len(sys.argv) > 1 else 7979 - url = f"http://localhost:{port}" - - server = http.server.HTTPServer(("", port), Handler) - print(f"\n🐦 Sparrow Dashboard") - print(f" Local: {url}") - print(f"\n Press Ctrl+C to stop\n") - - threading.Timer(0.8, lambda: webbrowser.open(url)).start() - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nStopped.") - - -if __name__ == "__main__": - main() diff --git a/.work/web/server.py b/.work/web/server.py new file mode 100644 index 0000000..3895575 --- /dev/null +++ b/.work/web/server.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +""" +Sparrow Web Dashboard +Usage: python3 server.py [port] +""" + +import http.server +import json +import os +import platform +import re +import subprocess +import sys +import threading +import webbrowser +from urllib.parse import urlparse + +BASE_PATH = os.environ.get("SPARROW_BASE_PATH", os.getcwd()) +SERVER_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _parse_root_env(): + """Read and parse root .env file into a dict.""" + root_env = os.path.join(BASE_PATH, ".env") + result = {} + if os.path.isfile(root_env): + with open(root_env, "r", encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "=" in line: + k, _, v = line.partition("=") + result[k.strip()] = v.strip() + return result + + +def _load_enable_service_list(): + """Read ENABLE_SERVICE_LIST from root .env file.""" + env = _parse_root_env() + if "ENABLE_SERVICE_LIST" in env: + val = env["ENABLE_SERVICE_LIST"].strip() + if val.startswith("(") and val.endswith(")"): + val = val[1:-1] + return re.findall(r'"([^"]+)"', val) + return [] + + +def _load_support_service_list(): + """Read SUPPORT_SERVICE_LIST from root .env file.""" + env = _parse_root_env() + if "SUPPORT_SERVICE_LIST" in env: + val = env["SUPPORT_SERVICE_LIST"].strip() + if val.startswith("(") and val.endswith(")"): + val = val[1:-1] + return re.findall(r'"([^"]+)"', val) + return [] + + +def parse_config_file(): + """Read CONTAINER_NAMESPACE from root .env.""" + return _parse_root_env() + + +def get_running_containers(): + """Return a set of running container names.""" + try: + r = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}"], + capture_output=True, text=True, timeout=5 + ) + return set(r.stdout.strip().splitlines()) + except Exception: + return set() + + +def get_local_images(): + """Return a set of 'repo:tag' strings of locally available images.""" + try: + r = subprocess.run( + ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], + capture_output=True, text=True, timeout=5 + ) + return set(r.stdout.strip().splitlines()) + except Exception: + return set() + + +def parse_env_file(service): + """Parse per-service variables from the root .env file.""" + root_env = os.path.join(BASE_PATH, ".env") + service_upper = service.upper() + images = [] + config = [] + if not os.path.isfile(root_env): + return images, config + + # We collect variables that belong to this service: + # 1. Variables prefixed with SERVICE_UPPER_ + # 2. Variables prefixed with IMAGE_*_SERVICE_UPPER_* + prefix = service_upper + "_" + with open(root_env, "r", encoding="utf-8") as f: + for raw in f: + line = raw.rstrip("\n") + stripped = line.strip() + if not stripped or stripped.startswith("###") or stripped.startswith("#"): + continue + if "=" not in line: + continue + key_part, _, rest = line.partition("=") + key = key_part.strip() + if "#" in rest: + value, _, comment = rest.partition("#") + value = value.strip() + comment = comment.strip() + else: + value = rest.strip() + comment = "" + + # Match image keys: IMAGE_(BASIC|APP|OFFICIAL)_{SERVICE}_... + is_image = bool(re.match( + r'^IMAGE_(BASIC|APP|OFFICIAL)_' + re.escape(service_upper) + r'(_|$)', key + )) + # Match config keys: {SERVICE}_... + is_config = key.startswith(prefix) + + if not is_image and not is_config: + continue + + entry = {"key": key, "value": value, "comment": comment} + if is_image: + images.append(entry) + else: + config.append(entry) + return images, config + + +def read_compose_file(service): + """Extract this service's block from the root docker-compose.yml.""" + root_compose = os.path.join(BASE_PATH, "docker-compose.yml") + if not os.path.isfile(root_compose): + return "" + + with open(root_compose, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Find the line where this service's block starts (2-space indented key under services:) + # e.g. " etcd:" or " mysql:" + service_pattern = re.compile(r'^ ' + re.escape(service) + r'\s*:') + # Any other top-level service key (2-space indent + word + colon, but not deeper indent) + next_service_pattern = re.compile(r'^ [a-zA-Z]') + + in_services = False + start = None + end = None + + for i, line in enumerate(lines): + if line.strip() == "services:": + in_services = True + continue + if not in_services: + continue + if start is None: + if service_pattern.match(line): + start = i + else: + # Stop at the next sibling service key (same 2-space indent, different name) + if next_service_pattern.match(line) and not service_pattern.match(line): + end = i + break + + if start is None: + return "" + block = lines[start:end] if end else lines[start:] + # Strip trailing blank lines + while block and not block[-1].strip(): + block.pop() + return "".join(block) + + +def build_data(): + cfg = parse_config_file() + namespace = cfg.get("CONTAINER_NAMESPACE", "") + running = get_running_containers() + local_images = get_local_images() + enabled_services = _load_enable_service_list() + support_services = _load_support_service_list() + + services = [] + for svc in support_services: + container_name = f"sparrow_container_{namespace}_{svc}" if namespace else f"sparrow_container_{svc}" + is_running = container_name in running + images, config = parse_env_file(svc) + compose = read_compose_file(svc) + has_env = svc in enabled_services + + # Enrich image entries with local availability + for img in images: + key = img["key"] + val = img["value"] + # Try to detect if this is a version key and pair with name key + img["local"] = False + if key.endswith("_VERSION") and val: + # e.g. IMAGE_BASIC_MYSQL_VERSION=8.0 → sparrow-basic-mysql:8.0 + m = re.match(r'^IMAGE_(BASIC|APP|OFFICIAL)_(.+)_VERSION$', key) + if m: + kind = m.group(1).lower() + svc_name = m.group(2).lower() + if kind == "official": + # official image name comes from IMAGE_OFFICIAL_{SVC}_NAME + name_key = f"IMAGE_OFFICIAL_{m.group(2)}_NAME" + name_entry = next((e["value"] for e in images if e["key"] == name_key), svc_name) + repo = f"{name_entry}:{val}" + else: + repo = f"sparrow-{kind}-{svc_name}:{val}" + img["repo"] = repo + img["local"] = repo in local_images + + services.append({ + "name": svc, + "container": container_name, + "running": is_running, + "hasEnv": has_env, + "images": images, + "config": config, + "compose": compose, + }) + + installed = [s for s in services if s["hasEnv"]] + running_list = [s for s in services if s["running"]] + return { + "namespace": namespace, + "services": services, + "runningCount": len(running_list), + "installedCount": len(installed), + "totalCount": len(services), + "stoppedCount": len(installed) - len(running_list), + } + + +def _load_html_template(): + """Load HTML template from index.html file.""" + index_path = os.path.join(SERVER_DIR, "index.html") + if os.path.isfile(index_path): + with open(index_path, "r", encoding="utf-8") as f: + return f.read() + return "" + +HTML_TEMPLATE = _load_html_template() + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass # suppress access logs + + def do_GET(self): + path = urlparse(self.path).path + if path == "/api/data": + data = json.dumps(build_data(), ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", len(data)) + self.end_headers() + self.wfile.write(data) + elif path == "/": + body = HTML_TEMPLATE.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", len(body)) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + path = urlparse(self.path).path + if path == "/api/action": + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + req = json.loads(body) + service = req.get("service", "").strip() + action = req.get("action", "").strip() + allowed_actions = {"startone", "stopone", "restart"} + if not service or action not in allowed_actions or not re.match(r'^[a-zA-Z0-9_-]+$', service): + raise ValueError("invalid service or action") + sparrow = os.path.join(BASE_PATH, "sparrow") + label = f"[web] ./sparrow {action} {service}" + print(f"\n{'─'*60}") + print(f"▶ {label}") + print(f"{'─'*60}", flush=True) + proc = subprocess.Popen( + [sparrow, action, service], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=BASE_PATH + ) + output_lines = [] + for line in proc.stdout: + line_stripped = line.rstrip("\n") + print(line_stripped, flush=True) + output_lines.append(line_stripped) + proc.wait() + ok = proc.returncode == 0 + print(f"{'─'*60}") + print(f"{'✓' if ok else '✗'} {label} (exit {proc.returncode})") + print(f"{'─'*60}\n", flush=True) + output_text = "\n".join(output_lines[-100:]) + resp = json.dumps({ + "ok": ok, + "stdout": output_text, + "error": "" if ok else output_lines[-1] if output_lines else "exit code " + str(proc.returncode), + }, ensure_ascii=False).encode("utf-8") + except Exception as e: + resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", len(resp)) + self.end_headers() + self.wfile.write(resp) + elif path == "/api/disable": + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + req = json.loads(body) + service = req.get("service", "").strip() + if not service or not re.match(r'^[a-zA-Z0-9_-]+$', service): + raise ValueError("invalid service") + + # Update only root .env file + root_env = os.path.join(BASE_PATH, ".env") + if os.path.isfile(root_env): + with open(root_env, "r", encoding="utf-8") as f: + lines = f.readlines() + + new_lines = [] + for line in lines: + if line.strip().startswith("ENABLE_SERVICE_LIST="): + # Extract the list content + list_str = line.split("=", 1)[1].strip() + if list_str.startswith("(") and list_str.endswith(")"): + list_content = list_str[1:-1] + # Parse quoted strings + items = re.findall(r'"([^"]+)"', list_content) + # Remove the service + new_items = [item for item in items if item != service] + # Rebuild the list + new_list_str = "(" + " ".join(f'"{item}"' for item in new_items) + ")" + new_line = re.sub(r'ENABLE_SERVICE_LIST\s*=\s*\([^)]*\)', f"ENABLE_SERVICE_LIST={new_list_str}", line) + new_lines.append(new_line) + else: + new_lines.append(line) + else: + new_lines.append(line) + + with open(root_env, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + label = f"[web] disabled service {service}" + print(f"\n{'─'*60}") + print(f"✓ {label}") + print(f"{'─'*60}\n", flush=True) + resp = json.dumps({"ok": True}, ensure_ascii=False).encode("utf-8") + except Exception as e: + resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", len(resp)) + self.end_headers() + self.wfile.write(resp) + elif path == "/api/enable": + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + req = json.loads(body) + service = req.get("service", "").strip() + if not service or not re.match(r'^[a-zA-Z0-9_-]+$', service): + raise ValueError("invalid service") + + # Update only root .env file + root_env = os.path.join(BASE_PATH, ".env") + if os.path.isfile(root_env): + with open(root_env, "r", encoding="utf-8") as f: + lines = f.readlines() + + new_lines = [] + for line in lines: + if line.strip().startswith("ENABLE_SERVICE_LIST="): + # Extract the list content + list_str = line.split("=", 1)[1].strip() + if list_str.startswith("(") and list_str.endswith(")"): + list_content = list_str[1:-1] + # Parse quoted strings + items = re.findall(r'"([^"]+)"', list_content) + # Add the service if not already present + if service not in items: + items.append(service) + # Rebuild the list + new_list_str = "(" + " ".join(f'"{item}"' for item in items) + ")" + new_line = re.sub(r'ENABLE_SERVICE_LIST\s*=\s*\([^)]*\)', f"ENABLE_SERVICE_LIST={new_list_str}", line) + new_lines.append(new_line) + else: + new_lines.append(line) + else: + new_lines.append(line) + + with open(root_env, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + label = f"[web] enabled service {service}" + print(f"\n{'─'*60}") + print(f"✓ {label}") + print(f"{'─'*60}\n", flush=True) + resp = json.dumps({"ok": True}, ensure_ascii=False).encode("utf-8") + except Exception as e: + resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", len(resp)) + self.end_headers() + self.wfile.write(resp) + else: + self.send_response(404) + self.end_headers() + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else 7979 + url = f"http://localhost:{port}" + + server = http.server.HTTPServer(("", port), Handler) + print(f"\n🐦 Sparrow Dashboard") + print(f" Local: {url}") + print(f"\n Press Ctrl+C to stop\n") + + threading.Timer(0.8, lambda: webbrowser.open(url)).start() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + + +if __name__ == "__main__": + main() diff --git a/sparrow b/sparrow index 43a0ece..5cf2500 100755 --- a/sparrow +++ b/sparrow @@ -128,7 +128,7 @@ restart() { # start web dashboard. web() { port=${1:-7979} - python3 "${SPARROW_BASE_PATH}/.work/include/web/server.py" "$port" + python3 "${SPARROW_BASE_PATH}/.work/web/server.py" "$port" } # enter container. From 0229ab1a623d2163ef12e691156fdc8b1075532c Mon Sep 17 00:00:00 2001 From: lvsi Date: Wed, 20 May 2026 19:37:37 +0800 Subject: [PATCH 2/7] feat: move html-template from server.py to index.html --- .work/web/index.html | 14 +++++++++----- .work/web/server.py | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.work/web/index.html b/.work/web/index.html index 09f3d71..12c5ab3 100644 --- a/.work/web/index.html +++ b/.work/web/index.html @@ -238,7 +238,7 @@
- +
@@ -308,7 +308,8 @@ function render() { if (!allData) return; - const q = document.getElementById('search').value.trim().toLowerCase(); + const searchEl = document.getElementById('search'); + const q = (searchEl.value || '').trim().toLowerCase(); let list = allData.services; if (filter === 'running') list = list.filter(s => s.running); @@ -485,10 +486,13 @@ currentModalService = null; } -// Close modal on Escape key +// Close modal on Escape key — capture phase runs before any input element handles ESC document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeModal(); -}); + if (e.key === 'Escape') { + e.preventDefault(); + closeModal(); + } +}, true); let toastTimer = null; function showToast(msg, type) { diff --git a/.work/web/server.py b/.work/web/server.py index 3895575..eb05ccf 100644 --- a/.work/web/server.py +++ b/.work/web/server.py @@ -263,7 +263,7 @@ def do_GET(self): self.end_headers() self.wfile.write(data) elif path == "/": - body = HTML_TEMPLATE.encode("utf-8") + body = _load_html_template().encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", len(body)) From f9e05ef37feecd218e8023e43b80d466de55d7f1 Mon Sep 17 00:00:00 2001 From: lvsi Date: Wed, 27 May 2026 14:12:32 +0800 Subject: [PATCH 3/7] feat: add search command and web add cloud images --- .work/include/internal/dockerhub.sh | 20 +++- .work/web/index.html | 178 +++++++++++++++++++++++++++- .work/web/server.py | 50 +++++++- sparrow | 23 ++++ 4 files changed, 266 insertions(+), 5 deletions(-) diff --git a/.work/include/internal/dockerhub.sh b/.work/include/internal/dockerhub.sh index f6b931b..ee2dbf0 100644 --- a/.work/include/internal/dockerhub.sh +++ b/.work/include/internal/dockerhub.sh @@ -24,11 +24,25 @@ search() { return 0 fi + print_info "start search image in dockerhub for public: $1... " + search_image=$1 remove_prefix_search_image=$(echo "$search_image" | sed 's/^docker.io\///') - echo "search search_image=${search_image}, remove_prefix_search_image=${remove_prefix_search_image}" - if docker search "$search_image" | grep -q "^$remove_prefix_search_image"; then - print_info "find it" + print_info "search search_image=${search_image}, remove_prefix_search_image=${remove_prefix_search_image}" + + # save and print the result of docker search + search_result=$(docker search "$search_image") + print_info "docker search result:" + echo "$search_result" | while IFS= read -r line; do echo " $line"; done + + # print the grep result + print_info "grep pattern: $remove_prefix_search_image" + + # check the match result (match anywhere in the line, not just start) + matched_lines=$(echo "$search_result" | grep "$remove_prefix_search_image") + if [ -n "$matched_lines" ]; then + print_info "find it, matched lines:" + echo "$matched_lines" | while IFS= read -r line; do echo " $line"; done return 0 # find else print_warn "not find it" diff --git a/.work/web/index.html b/.work/web/index.html index 12c5ab3..2b3cf49 100644 --- a/.work/web/index.html +++ b/.work/web/index.html @@ -222,6 +222,78 @@ .cy-bool { color: #cf222e; } /* true/false */ .cy-var { color: #8250df; font-weight: 600; } /* ${VAR} */ .cy-comment{ color: #8b949e; font-style: italic; } + +/* Footer */ +.footer { + background: var(--surface); border-top: 1px solid var(--border); + padding: 20px; margin-top: 40px; +} +.footer-content { + max-width: 1440px; margin: 0 auto; text-align: center; +} +.footer-link { + color: var(--text2); text-decoration: none; font-size: 13px; + display: inline-flex; align-items: center; gap: 6px; + transition: color .15s; +} +.footer-link:hover { color: var(--blue); } +.github-icon { flex-shrink: 0; } + +/* Docker Hub Panel */ +.dockerhub-panel { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px 20px; margin-bottom: 20px; +} +.dockerhub-header { + display: flex; align-items: center; gap: 10px; margin-bottom: 10px; +} +.dockerhub-title { + font-size: 14px; font-weight: 600; color: var(--text); +} +.dockerhub-btn { + background: var(--surface2); border: 1px solid var(--border); + color: var(--text2); padding: 6px 12px; border-radius: var(--radius); + cursor: pointer; font-size: 13px; transition: all .15s; +} +.dockerhub-btn:hover { border-color: var(--border2); color: var(--text); } +.dockerhub-content { + display: none; margin-top: 12px; padding-top: 12px; + border-top: 1px solid var(--border); +} +.dockerhub-content.show { display: block; } +.dockerhub-config { + font-family: var(--mono); font-size: 13px; color: var(--text2); + background: var(--surface2); padding: 12px; border-radius: 8px; + margin-bottom: 12px; +} +.dockerhub-config-line { + margin-bottom: 4px; +} +.dockerhub-config-line:last-child { margin-bottom: 0; } +.dockerhub-config-key { + color: var(--purple); font-weight: 600; +} +.dockerhub-images { + margin-top: 12px; +} +.dockerhub-images-title { + font-size: 12px; font-weight: 700; color: var(--text2); + text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px; +} +.dockerhub-image-list { + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 8px; +} +.dockerhub-image-item { + background: var(--surface2); border: 1px solid var(--border); + border-radius: 8px; padding: 10px 12px; font-family: var(--mono); + font-size: 12px; color: var(--text); word-break: break-all; +} + +/* Cloud status badge */ +.img-cloud { font-size: 11px; padding: 2px 8px; border-radius: 4px; flex-shrink: 0; font-weight: 600; } +.img-cloud.yes { background: var(--blue-bg); color: var(--blue); border: 1px solid rgba(9,105,218,0.3); } +.img-cloud.no { background: #fff; color: var(--text3); border: 1px solid var(--border); } @@ -237,6 +309,21 @@
+ + +
@@ -251,6 +338,18 @@
+ + +