diff --git a/CLAUDE.md b/CLAUDE.md index e6ec608..dd3a96c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,27 +205,154 @@ form "name" ``` ### Dynamic Features -``` -cache 0 # never cache (re-execute) -source cpu : shell "cat /proc/loadavg" # live data at render time -source config : json "/path/config.json" # JSON file read -source ts : python "datetime.now().isoformat()" # Python expression -let name = "Relay Alpha" # variable assignment +Any page using `source`, `if`, `for`, `on_submit`, or `state` becomes **dynamic**: it compiles to an executable Python script instead of static Micron. NomadNet runs the script on each request and serves its stdout. + +#### Variables +``` +let name = "Relay Alpha" # string assignment +let threshold = 75 # numeric +let tags = "alpha","beta","gamma" # comma-separated → list +``` +Variables are substituted with `$name` in text, labels, and other content. They work in both static and dynamic pages. + +#### Data Sources +``` +source var_name : type "command" [timeout=N] +``` +Sources fetch data **at render time** and bind results to variables: + +| Type | Description | Example | +|----------|--------------------------------------|----------------------------------------------------------| +| `shell` | Run shell command, capture stdout | `source cpu : shell "cat /proc/loadavg"` | +| `file` | Read file contents as string | `source motd : file "/etc/motd"` | +| `json` | Read + parse JSON file → dict/list | `source config : json "/etc/config.json"` | +| `python` | Evaluate Python expression | `source ts : python "datetime.now().strftime('%H:%M')"` | +| `http` | HTTP request, auto-parses JSON | `source data : http "https://api.example.com/data"` | +| `sqlite` | SQLite query → list of dicts | `source users : sqlite "/path/db" "SELECT * FROM users"` | +| `env` | Read environment variable | `source key : env "API_KEY"` | +| `param` | Read URL parameter from link | `source hash : param "hash"` | +| `rns` | Query Reticulum via `rnstatus` | `source peers : rns "peers"` | + +**Shell** commands have a default 5-second timeout (override with `timeout=N`). + +**Python** expressions have access to: `datetime` (the class, so `datetime.now()` works), `timedelta`, `secrets`, `os`, `json`. Expressions are evaluated via `eval()` — single expressions only, not statements. + +``` +# Python source examples +source timestamp : python "datetime.now().strftime('%H:%M:%S')" +source rand_id : python "secrets.token_hex(4)" +source cpu_sim : python "secrets.randbelow(60) + 20" +source uptime : python "str(timedelta(seconds=12345))" +source hostname : python "os.uname().nodename" +``` + +**HTTP** requests return parsed JSON (dict/list) or raw string. Default timeout 10s. + +``` +# GET request — JSON auto-parsed into dict +source todo : http "https://api.example.com/todos/1" +text "Title: $todo.title" + +# POST with JSON body +source result : http "https://api.example.com/search" method=POST body='{"q":"relay"}' + +# Custom headers (semicolon-separated) +source data : http "https://api.example.com/data" headers='Authorization: Bearer tok123' + +# Use $var references in URL, headers, and body — resolved at runtime +source token : env "API_TOKEN" +source data : http "https://api.example.com/data" headers='Authorization: Bearer $token' +``` + +**Env** reads server-side environment variables. Use this for secrets — tokens never appear in `.uf` source or compiled scripts. + +``` +source api_key : env "API_KEY" +source db_pass : env "DB_PASSWORD" +``` + +**SQLite** queries return a list of dicts (or a single dict for one row). Uses Python stdlib `sqlite3`. + +``` +# Query returns list of dicts with column names as keys +source nodes : sqlite "/data/network.db" "SELECT name, status, hops FROM nodes" + +# Iterate results +for node in $nodes + label "$node.name" "$node.status ($node.hops hops)" + +# Single row queries return a dict directly +source config : sqlite "/data/app.db" "SELECT value FROM config WHERE key='theme'" +text "Theme: $config.value" +``` + +#### Conditionals +``` if $cpu > 90 text "ALERT: CPU critical" elif $cpu > 75 text "Warning: elevated" +else + text "All clear" +``` +Conditions are Python expressions. `$var` references resolve to the variable's value. Supports `>`, `<`, `>=`, `<=`, `==`, `!=`, `&&` (and), `||` (or). +#### Loops +``` for peer in $peers status "$peer.name" $peer.state - -on_submit "search" - source results : shell "search.py '$query'" - text "$results" - -state "counter" "/tmp/counter.json" # persistent JSON store ``` +Iterates over lists (from JSON sources), dicts (wrapped as single-item list), or newline-delimited strings (from shell output). Access nested fields with `$item.field`. + +#### Cache Control +``` +cache 0 # never cache (re-execute every request) +cache 300 # cache for 5 minutes +``` +Emits the `#!c=N` header that NomadNet uses to control page caching. + +#### Form Submission Handling +``` +on_submit "form_name" + # Runs when the named form is submitted + # Form field values are available as $field_name + source results : shell "search.py '$query'" + text "Found: $results" +``` +Field values are read from `FIELD_*` environment variables set by NomadNet. + +#### Persistent State +``` +state "counter" "/tmp/counter.json" # load JSON into $counter +``` +Loads a JSON file into a variable. Use `_save_state(path, data)` in the generated script to persist changes. + +#### Using Variables in Content +``` +text "Hello, $name" # inline substitution +label "CPU" "$cpu_pct%" # in labels +gauge "CPU" $cpu_pct 100 28 warn=75 crit=90 # as gauge values +status "$peer" $state # in status indicators +link "View $name" "/page/detail.mu" # in links +``` + +#### Generated Script Runtime + +The compiled script includes these helpers, available in `on_submit` and source blocks: + +| Helper | Description | +|-------------------------------------|-----------------------------------------------| +| `_shell(cmd, timeout=5)` | Execute shell command, return stdout | +| `_read_file(path)` | Read file contents | +| `_read_json(path)` | Read + parse JSON file | +| `_http(url, method, body, headers)` | HTTP request, auto-parse JSON response | +| `_sqlite(db_path, query)` | SQLite query → list of dicts (or single dict) | +| `_get_field(name, default)` | Read submitted form field | +| `_get_param(name, default)` | Read URL parameter | +| `_load_state(path)` | Load state from JSON file | +| `_save_state(path, data)` | Save state to JSON file | +| `_iter(val)` | Make a value iterable (list/dict/string) | ### Components ``` diff --git a/backend/browse.py b/backend/browse.py index 776576f..6e78ac0 100644 --- a/backend/browse.py +++ b/backend/browse.py @@ -264,12 +264,19 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")): # --------------------------------------------------------------------------- def _read_local_page(path: str) -> dict: + from converter import execute_dynamic_script + pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages")) filepath = Path(pages_dir) / path - try: - return {"content": filepath.read_text()} - except FileNotFoundError: + if not filepath.exists(): return {"content": None, "error": "Page not found"} + try: + if os.access(filepath, os.X_OK): + script = filepath.read_text(encoding="utf-8") + return {"content": execute_dynamic_script(script)} + return {"content": filepath.read_text()} + except Exception as exc: + return {"content": None, "error": str(exc)} # --------------------------------------------------------------------------- diff --git a/backend/converter.py b/backend/converter.py index b23480e..2c9bbc7 100644 --- a/backend/converter.py +++ b/backend/converter.py @@ -1,6 +1,9 @@ """µFrame compile, DSL metadata, and image upload endpoints.""" import os +import subprocess +import sys +import tempfile from pathlib import Path from fastapi import APIRouter, HTTPException, UploadFile, File @@ -14,6 +17,31 @@ from uframe.registry import get_dsl_meta router = APIRouter() UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images" +BACKEND_DIR = str(Path(__file__).resolve().parent) + + +def execute_dynamic_script(script: str, timeout: int = 10) -> str: + """Execute a dynamic page script and return its stdout (micron output). + + Used by both the compile preview and the browse page reader. + """ + env = {**os.environ, "PYTHONPATH": BACKEND_DIR} + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write(script) + f.flush() + try: + result = subprocess.run( + [sys.executable, f.name], + capture_output=True, text=True, timeout=timeout, + cwd=BACKEND_DIR, env=env, + ) + if result.returncode != 0 and result.stderr: + return result.stderr + return result.stdout + except subprocess.TimeoutExpired: + return "Error: script timed out" + finally: + os.unlink(f.name) class CompileRequest(BaseModel): @@ -31,12 +59,26 @@ class CompileResponse(BaseModel): @router.post("/compile", response_model=CompileResponse) async def compile_source(req: CompileRequest): - """Compile µFrame .uf source into ASCII and Micron output.""" + """Compile µFrame .uf source into ASCII and Micron output. + + For dynamic pages, the generated script is executed and the + resolved micron output replaces the static micron in the response. + """ try: result = uframe.compile(req.source, width=req.width) + + micron = result.micron + if result.is_dynamic and result.script: + executed = execute_dynamic_script(result.script) + # Strip cache header line if present + lines = executed.split("\n") + if lines and lines[0].startswith("#!c="): + lines = lines[1:] + micron = "\n".join(lines) + return CompileResponse( ascii=result.ascii, - micron=result.micron, + micron=micron, script=result.script, is_dynamic=result.is_dynamic, warnings=[w.message for w in result.warnings], diff --git a/backend/main.py b/backend/main.py index e614923..fff3c43 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from pages import router as pages_router, ensure_default_pages +from pages import router as pages_router, files_router, ensure_default_pages from docker_utils import router as docker_router from converter import router as converter_router from browse import router as browse_router, start_browser @@ -13,6 +13,7 @@ app = FastAPI(title="µFrame Editor") app.include_router(converter_router, prefix="/api") app.include_router(pages_router, prefix="/api") +app.include_router(files_router, prefix="/api") app.include_router(docker_router, prefix="/api") app.include_router(browse_router, prefix="/api") diff --git a/backend/pages.py b/backend/pages.py index 9cf0bc1..2b188af 100644 --- a/backend/pages.py +++ b/backend/pages.py @@ -1,13 +1,15 @@ import os import shlex +import shutil from pathlib import Path -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field import uframe router = APIRouter() +files_router = APIRouter() PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) @@ -38,7 +40,7 @@ page "Welcome" 60 def ensure_default_pages(): - """Create a default index page if none exists.""" + """Create default index page and .env file if they don't exist.""" PAGES_DIR.mkdir(parents=True, exist_ok=True) SOURCES_DIR.mkdir(parents=True, exist_ok=True) @@ -54,6 +56,17 @@ def ensure_default_pages(): if not index_src.is_file(): index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8") + env_path = SOURCES_DIR / ".env" + if not env_path.is_file(): + env_path.write_text( + "# Environment variables for dynamic pages\n" + "# Access with: source name : env \"KEY\"\n" + "#\n" + "# Example:\n" + "# API_KEY=your-key-here\n", + encoding="utf-8", + ) + class PageMeta(BaseModel): name: str @@ -225,3 +238,176 @@ async def delete_page(name: str): mu_path.unlink() return {"deleted": name} + + +# --------------------------------------------------------------------------- +# File browser endpoints +# --------------------------------------------------------------------------- + + +class FileEntry(BaseModel): + name: str + path: str + type: str # "file" | "folder" | "env" + size: int | None = None + last_modified: float | None = None + title: str | None = None + published: bool = False + + +class MkdirRequest(BaseModel): + path: str + + +class MoveRequest(BaseModel): + model_config = {"populate_by_name": True} + from_path: str = Field(alias="from") + to: str + + +class EnvRequest(BaseModel): + content: str + + +def _validate_relative_path(rel: str) -> Path: + """Validate that a relative path has no traversal components and resolves + inside the expected base directories. Returns the cleaned relative Path.""" + p = Path(rel) + # Reject absolute paths and any ".." components + if p.is_absolute(): + raise HTTPException(status_code=400, detail="Absolute paths not allowed") + for part in p.parts: + if part == "..": + raise HTTPException(status_code=400, detail="Directory traversal not allowed") + # Extra safety: resolve against SOURCES_DIR and verify containment + resolved = (SOURCES_DIR / p).resolve() + if not str(resolved).startswith(str(SOURCES_DIR.resolve())): + raise HTTPException(status_code=400, detail="Path escapes base directory") + return p + + +def _file_entry(base: Path, rel_path: Path) -> FileEntry: + """Build a FileEntry for a file or directory at base/rel_path.""" + full = base / rel_path + name = rel_path.name + + if full.is_dir(): + return FileEntry( + name=name, + path=str(rel_path), + type="folder", + ) + + # .env file + if name == ".env": + stat = full.stat() + return FileEntry( + name=name, + path=str(rel_path), + type="env", + size=stat.st_size, + last_modified=stat.st_mtime, + ) + + # Regular file + stat = full.stat() + title = None + published = False + + if full.suffix == ".uf": + try: + title = _extract_title(full.read_text(encoding="utf-8")) + except Exception: + pass + # Check published status: corresponding .mu in PAGES_DIR at same relative path + mu_rel = rel_path.with_suffix(".mu") + published = (PAGES_DIR / mu_rel).is_file() + + return FileEntry( + name=name, + path=str(rel_path), + type="file", + size=stat.st_size, + last_modified=stat.st_mtime, + title=title, + published=published, + ) + + +@files_router.get("/files", response_model=list[FileEntry]) +async def list_files(path: str = Query(default="")): + """List files and folders in SOURCES_DIR, optionally scoped to a subfolder.""" + if path: + rel = _validate_relative_path(path) + else: + rel = Path(".") + + target = (SOURCES_DIR / rel).resolve() + if not str(target).startswith(str(SOURCES_DIR.resolve())): + raise HTTPException(status_code=400, detail="Path escapes base directory") + if not target.is_dir(): + raise HTTPException(status_code=404, detail="Directory not found") + + entries: list[FileEntry] = [] + for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): + item_rel = item.relative_to(SOURCES_DIR) + entries.append(_file_entry(SOURCES_DIR, item_rel)) + + return entries + + +@files_router.post("/files/mkdir") +async def mkdir(req: MkdirRequest): + """Create a folder in both SOURCES_DIR and PAGES_DIR.""" + rel = _validate_relative_path(req.path) + + (SOURCES_DIR / rel).mkdir(parents=True, exist_ok=True) + (PAGES_DIR / rel).mkdir(parents=True, exist_ok=True) + + return {"created": str(rel)} + + +@files_router.post("/files/move") +async def move_file(req: MoveRequest): + """Move/rename a file or folder in both SOURCES_DIR and PAGES_DIR.""" + from_rel = _validate_relative_path(req.from_path) + to_rel = _validate_relative_path(req.to) + + # Move in SOURCES_DIR + src_from = SOURCES_DIR / from_rel + src_to = SOURCES_DIR / to_rel + if src_from.exists(): + src_to.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src_from), str(src_to)) + + # Move in PAGES_DIR (for .uf files, look for .mu counterpart) + if src_from.suffix == ".uf" or (not src_from.exists() and from_rel.suffix == ".uf"): + pages_from = PAGES_DIR / from_rel.with_suffix(".mu") + pages_to = PAGES_DIR / to_rel.with_suffix(".mu") + else: + pages_from = PAGES_DIR / from_rel + pages_to = PAGES_DIR / to_rel + + if pages_from.exists(): + pages_to.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(pages_from), str(pages_to)) + + return {"moved": {"from": str(from_rel), "to": str(to_rel)}} + + +@files_router.get("/files/env") +async def read_env(): + """Read the .env file from SOURCES_DIR root.""" + env_path = SOURCES_DIR / ".env" + if env_path.is_file(): + return {"content": env_path.read_text(encoding="utf-8")} + return {"content": ""} + + +@files_router.post("/files/env") +async def save_env(req: EnvRequest): + """Save the .env file to SOURCES_DIR root.""" + SOURCES_DIR.mkdir(parents=True, exist_ok=True) + env_path = SOURCES_DIR / ".env" + env_path.write_text(req.content, encoding="utf-8") + return {"saved": True} diff --git a/backend/uframe/codegen.py b/backend/uframe/codegen.py index a5c7b60..6220e61 100644 --- a/backend/uframe/codegen.py +++ b/backend/uframe/codegen.py @@ -29,14 +29,56 @@ def _indent(code: str, level: int = 1) -> str: def _resolve_vars(text: str) -> str: - """Convert $var references to Python f-string expressions.""" + """Convert $var references to Python f-string expressions. + + First escapes literal braces (e.g. @color{ff0}{text} → @color{{ff0}}{{text}}) + so they survive f-string evaluation, then replaces $var → {var}. + """ + import re + # Use a unique placeholder for $var refs, escape all braces, then restore + _PH = "\x00VAR" + counter = [0] + placeholders: dict[str, str] = {} + + def stash_var(m: re.Match) -> str: + var = m.group(1) + if "." in var: + parts = var.split(".") + base = parts[0] + chain = "".join(f"['{p}']" for p in parts[1:]) + expr = "{" + base + chain + "}" + else: + expr = "{" + var + "}" + key = f"{_PH}{counter[0]}\x00" + counter[0] += 1 + placeholders[key] = expr + return key + + # 1. Stash $var references with placeholders + result = re.sub(r'\$([a-zA-Z_][\w.]*)', stash_var, text) + # 2. Escape all remaining braces for f-string safety + result = result.replace("{", "{{").replace("}", "}}") + # 3. Restore $var placeholders (unescaped) + for key, expr in placeholders.items(): + result = result.replace(key, expr) + return result + + +def _resolve_vars_code(text: str) -> str: + """Convert $var references to bare Python identifiers. + + Simple vars: $name → name + Dotted paths: $item.name → item['name'] (dict access) + """ import re - # Replace $var.attr.attr with {var_attr_attr} and simple $var with {var} def replace_var(m: re.Match) -> str: var = m.group(1) - # Replace dots with underscores for Python variable names - py_var = var.replace(".", "_") - return "{" + py_var + "}" + if "." in var: + parts = var.split(".") + base = parts[0] + chain = "".join(f"['{p}']" for p in parts[1:]) + return base + chain + return var return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text) @@ -67,14 +109,28 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]: elif node.source_type == SourceType.JSON: lines.append(f"{ind}{var} = _read_json({node.command!r})") elif node.source_type == SourceType.PYTHON: - # Restricted eval — only datetime/secrets modules available - lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})") + lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})") elif node.source_type == SourceType.PARAM: lines.append(f"{ind}{var} = _get_param({node.command!r})") elif node.source_type == SourceType.RNS: import shlex as _shlex safe_cmd = _shlex.quote(node.command) lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})") + elif node.source_type == SourceType.HTTP: + method = node.http_method or "GET" + url = _resolve_vars(node.command) + hdrs = _resolve_vars(node.http_headers) if node.http_headers else "" + body = _resolve_vars(node.http_body) if node.http_body else "" + if body: + lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, body=f'''{body}''', headers=f'''{hdrs}''', timeout={node.timeout})""") + elif hdrs: + lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, headers=f'''{hdrs}''', timeout={node.timeout})""") + else: + lines.append(f"{ind}{var} = _http({node.command!r}, method={method!r}, timeout={node.timeout})") + elif node.source_type == SourceType.SQLITE: + lines.append(f"{ind}{var} = _sqlite({node.command!r}, {node.query!r})") + elif node.source_type == SourceType.ENV: + lines.append(f"{ind}{var} = os.environ.get({node.command!r}, '')") elif isinstance(node, CacheControl): lines.append(f"{ind}_cache_seconds = {node.seconds}") @@ -83,8 +139,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]: lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})") elif isinstance(node, IfBlock): - cond = _resolve_vars(node.condition) - # Convert simple comparisons + cond = _resolve_vars_code(node.condition) cond = cond.replace("&&", " and ").replace("||", " or ") lines.append(f"{ind}if {cond}:") if node.children: @@ -94,7 +149,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]: lines.append(f"{ind} pass") for elif_cond, elif_children in node.elif_branches: - ec = _resolve_vars(elif_cond).replace("&&", " and ").replace("||", " or ") + ec = _resolve_vars_code(elif_cond).replace("&&", " and ").replace("||", " or ") lines.append(f"{ind}elif {ec}:") if elif_children: for child in elif_children: @@ -108,7 +163,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]: lines.extend(_emit_node(child, indent_level + 1)) elif isinstance(node, ForLoop): - iterable = _resolve_vars(node.iterable) + iterable = _resolve_vars_code(node.iterable) lines.append(f"{ind}for {node.var_name} in _iter({iterable}):") if node.children: for child in node.children: @@ -146,13 +201,16 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]: elif isinstance(node, Gauge): label = _resolve_vars(node.label) - value = _resolve_vars(str(node.value)) if "$" in str(node.value) else str(node.value) + raw_val = getattr(node, "_raw_value", None) + value = _resolve_vars(raw_val) if raw_val and "$" in raw_val else str(node.value) + raw_max = getattr(node, "_raw_max_val", None) + max_val = _resolve_vars(raw_max) if raw_max and "$" in raw_max else str(node.max_val) extra = "" if node.warn is not None: extra += f" warn={node.warn}" if node.crit is not None: extra += f" crit={node.crit}" - lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {node.max_val} {node.bar_width}{extra}')") + lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {max_val} {node.bar_width}{extra}')") elif isinstance(node, Status): label = _resolve_vars(node.label) @@ -221,6 +279,7 @@ def _build_script(uframe_import: str, page_logic: str, page_title: str, page_wid "# Do not edit — regenerate with: uframe compile .uf", "", "import os, sys, json, subprocess, datetime, secrets, shlex", + "from datetime import datetime as _dt_cls, timedelta", "", "# ─── Runtime Helpers ─────────────────────────────────────────", "", @@ -279,6 +338,42 @@ def _build_script(uframe_import: str, page_logic: str, page_title: str, page_wid ' return val.strip().splitlines()', ' return []', "", + 'def _http(url, method="GET", body="", headers="", timeout=10):', + ' """HTTP request, return response body (JSON parsed if possible)."""', + ' import urllib.request, urllib.error', + ' try:', + ' data = body.encode("utf-8") if body else None', + ' req = urllib.request.Request(url, data=data, method=method)', + ' req.add_header("User-Agent", "uframe/1.0")', + ' if body and not headers:', + ' req.add_header("Content-Type", "application/json")', + ' if headers:', + ' for pair in headers.split(";"):', + ' if ":" in pair:', + ' k, v = pair.split(":", 1)', + ' req.add_header(k.strip(), v.strip())', + ' with urllib.request.urlopen(req, timeout=timeout) as resp:', + ' raw = resp.read().decode("utf-8")', + ' try:', + ' return json.loads(raw)', + ' except (json.JSONDecodeError, ValueError):', + ' return raw.strip()', + ' except Exception as e:', + ' return {"error": str(e)}', + "", + 'def _sqlite(db_path, query):', + ' """Run a SQLite query, return list of dicts."""', + ' import sqlite3', + ' try:', + ' conn = sqlite3.connect(db_path)', + ' conn.row_factory = sqlite3.Row', + ' cur = conn.execute(query)', + ' rows = [dict(r) for r in cur.fetchall()]', + ' conn.close()', + ' return rows if len(rows) != 1 else rows[0]', + ' except Exception as e:', + ' return {"error": str(e)}', + "", f"# ─── µFrame Compile ──────────────────────────────────────────", "", uframe_import, diff --git a/backend/uframe/emit_micron.py b/backend/uframe/emit_micron.py index 0924c12..53df658 100644 --- a/backend/uframe/emit_micron.py +++ b/backend/uframe/emit_micron.py @@ -78,8 +78,9 @@ def emit_micron(grid: CharGrid, page_title: str = "") -> str: if cur_style != _EMPTY_STYLE: line_parts.append(_emit_style_close(cur_style)) cur_style = _EMPTY_STYLE - # Open new link - line_parts.append("[") + # Open new link — backtick enters formatting mode + # where the parser recognizes `[` as link start + line_parts.append("`[") in_link = link # Handle style transitions (not inside links — links handle their own style) diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index b2509d7..7112c4f 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -302,6 +302,9 @@ class SourceType(Enum): PYTHON = auto() RNS = auto() PARAM = auto() + HTTP = auto() + SQLITE = auto() + ENV = auto() @dataclass @@ -317,6 +320,12 @@ class Source(IRNode): var_name: str = "" source_type: SourceType = SourceType.SHELL command: str = "" + # HTTP-specific + http_method: str = "GET" + http_body: str = "" + http_headers: str = "" + # SQLite-specific: command = db path, query = SQL + query: str = "" timeout: int = 5 diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index 0920974..9a8cb63 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -138,7 +138,7 @@ def _parse_list_style(s: str) -> ListStyle: }.get(s.lower(), ListStyle.BULLET) -def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: +def _parse_line(keyword: str, args: list[str], line_num: int, raw_args: str = "") -> IRNode: """Parse a single line into an IR node based on the keyword.""" if keyword == "page": @@ -364,7 +364,7 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: # Dynamic features elif keyword == "let": # let name = "value" or let name = 1,2,3 - raw = " ".join(args) + raw = raw_args eq = raw.find("=") if eq != -1: var_name = raw[:eq].strip() @@ -377,11 +377,10 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: elif keyword == "source": # source cpu : shell "grep 'cpu' /proc/stat" # source name : type "command" - raw = " ".join(args) - colon = raw.find(":") + colon = raw_args.find(":") if colon != -1: - var_name = raw[:colon].strip() - rest = raw[colon + 1:].strip() + var_name = raw_args[:colon].strip() + rest = raw_args[colon + 1:].strip() parts = _split_args(rest) src_type_str = parts[0] if parts else "shell" command = parts[1] if len(parts) > 1 else "" @@ -392,17 +391,38 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: "python": SourceType.PYTHON, "rns": SourceType.RNS, "param": SourceType.PARAM, + "http": SourceType.HTTP, + "sqlite": SourceType.SQLITE, + "env": SourceType.ENV, }.get(src_type_str.lower(), SourceType.SHELL) - # Parse optional timeout + # Parse optional params from remaining parts timeout = 5 - for p in parts[2:]: - if p.startswith("timeout"): + http_method = "GET" + http_body = "" + http_headers = "" + query = "" + extra = parts[2:] + if src_type == SourceType.SQLITE and len(parts) > 2: + # sqlite "/path/db" "SELECT ..." + query = parts[2] + extra = parts[3:] + for p in extra: + if p.startswith("timeout="): try: - timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1]) + timeout = int(p.split("=", 1)[1]) except (ValueError, IndexError): pass + elif p.startswith("method="): + http_method = p.split("=", 1)[1].upper() + elif p.startswith("body="): + http_body = p.split("=", 1)[1] + elif p.startswith("headers="): + http_headers = p.split("=", 1)[1] return Source(var_name=var_name, source_type=src_type, - command=command, timeout=timeout, source_line=line_num) + command=command, timeout=timeout, + http_method=http_method, http_body=http_body, + http_headers=http_headers, query=query, + source_line=line_num) else: return Source(var_name=args[0] if args else "", source_line=line_num) @@ -817,7 +837,7 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag args = _split_args(arg_str) # Parse this line into a node - node = _parse_line(keyword, args, line_num) + node = _parse_line(keyword, args, line_num, raw_args=arg_str) # Pop stack back to find the parent (parent indent < this indent) while stack and stack[-1][0] >= indent: diff --git a/backend/uframe/tests/test_dynamic.py b/backend/uframe/tests/test_dynamic.py index 3f66991..90826ba 100644 --- a/backend/uframe/tests/test_dynamic.py +++ b/backend/uframe/tests/test_dynamic.py @@ -36,7 +36,7 @@ def test_if_block(): text "Critical"''' result = uframe.compile(source) assert result.is_dynamic - assert "if {val} > 90:" in result.script + assert "if val > 90:" in result.script def test_for_loop(): @@ -46,7 +46,7 @@ def test_for_loop(): text "$item"''' result = uframe.compile(source) assert result.is_dynamic - assert "for item in _iter({items}):" in result.script + assert "for item in _iter(items):" in result.script def test_let_variable(): @@ -120,5 +120,5 @@ def test_codegen_complete_dashboard(): assert "#!/usr/bin/env python3" in script assert "_cache_seconds = 0" in script assert "_shell" in script - assert "if {cpu} > 90:" in script + assert "if cpu > 90:" in script assert "uframe.compile" in script diff --git a/docs/dynamic-templates.md b/docs/dynamic-templates.md index 3dd9d41..c7fd2a0 100644 --- a/docs/dynamic-templates.md +++ b/docs/dynamic-templates.md @@ -158,9 +158,11 @@ source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(s source motd : file "/etc/motd" source config : json "/home/node/.nomadnetwork/config.json" -# Python expression — evaluated inline +# Python expression — evaluated inline (available: datetime, timedelta, secrets, os, json) source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')" source rand_hex : python "secrets.token_hex(4)" +source uptime : python "str(timedelta(seconds=12345))" +source hostname : python "os.uname().nodename" # RNS/Reticulum API — direct integration source peer_list : rns "peers" @@ -346,135 +348,95 @@ A `.uf` file with dynamic features compiles into a Python script that: 1. Sets the shebang and cache header -2. Imports required modules -3. Reads environment variables (form data) -4. Executes source commands (shell, file, python, rns) -5. Evaluates conditionals and loops -6. Renders the IR tree into a CharGrid -7. Emits the CharGrid as Micron with style tags -8. Prints to stdout +2. Imports required modules + the `uframe` package +3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.) +4. Executes source commands and evaluates conditionals/loops +5. Dynamically builds a `.uf` source string with resolved variables +6. Compiles that source with `uframe.compile()` at runtime +7. Prints the resulting Micron to stdout ```python #!/usr/bin/env python3 -#!c=0 -# Auto-generated by µFrame from dashboard.uf -# Do not edit — regenerate with: uframe compile dashboard.uf +# Auto-generated by uFrame +# Do not edit — regenerate with: uframe compile .uf -import os, sys, json, subprocess, datetime, secrets +import os, sys, json, subprocess, datetime, secrets, shlex +from datetime import datetime as _dt_cls, timedelta -# ─── µFrame Runtime (embedded) ─────────────────────────────── +# ─── Runtime Helpers ───────────────────────────────────────── -class CharGrid: - """2D character grid with style annotations.""" - def __init__(self, width, height): - self.w = width - self.h = height - self.chars = [[' ']*width for _ in range(height)] - self.styles = [[None]*width for _ in range(height)] - - def put(self, x, y, ch, style=None): - if 0 <= x < self.w and 0 <= y < self.h: - self.chars[y][x] = ch - self.styles[y][x] = style - - def box(self, x, y, w, h, weight='light', title=None, title_style=None): - """Draw a box with automatic border characters.""" - # ... border drawing logic ... - - def gauge(self, x, y, w, value, max_val, label=None, - warn=None, crit=None): - """Render a horizontal gauge bar with threshold colors.""" - pct = min(value / max_val, 1.0) - filled = int(w * pct) - for i in range(w): - ch = '█' if i < filled else '░' - fg = None - if crit and value >= crit: fg = 'f00' - elif warn and value >= warn: fg = 'ff0' - elif i < filled: fg = '0f0' - else: fg = '555' - self.put(x + i, y, ch, {'fg': fg}) - # ... label and percentage ... - - def sparkline(self, x, y, w, values): - """Render braille sparkline from value array.""" - # ... braille pattern generation ... - - def emit_micron(self): - """Scan grid and emit Micron with style tags.""" - lines = [] - for row_idx in range(self.h): - line = [] - cur_style = None - for col_idx in range(self.w): - ch = self.chars[row_idx][col_idx] - st = self.styles[row_idx][col_idx] - if st != cur_style: - # Close previous style tags - if cur_style: - if cur_style.get('fg'): line.append('`f') - if cur_style.get('bold'): line.append('`!') - # Open new style tags - if st: - if st.get('bold'): line.append('`!') - if st.get('fg'): line.append(f'`F{st["fg"]}') - cur_style = st - line.append(ch) - # Close final style - if cur_style: - if cur_style.get('fg'): line.append('`f') - if cur_style.get('bold'): line.append('`!') - lines.append(''.join(line).rstrip()) - return '\n'.join(lines) - -# ─── Form Data ─────────────────────────────────────────────── - -def get_field(name, default=''): - """Read submitted form field from environment.""" - return os.environ.get(f'FIELD_{name}', default) - -def get_param(name, default=''): - """Read URL parameter.""" - return os.environ.get(f'PARAM_{name}', - os.environ.get(f'var_{name}', default)) - -# ─── Data Sources ──────────────────────────────────────────── - -def shell(cmd): +def _shell(cmd, timeout=5): """Execute shell command, return stdout.""" try: - return subprocess.check_output( - cmd, shell=True, timeout=5 - ).decode().strip() + return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip() except Exception: - return '' + return "" -# ─── Resolve Sources ───────────────────────────────────────── +def _read_file(path): + """Read file contents.""" + # ... -cpu_pct = int(shell( - "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" -) or 0) -mem_pct = int(shell( - "free | awk '/Mem/{print int($3/$2*100)}'" -) or 0) -uptime_str = shell("uptime -p") -peer_count = shell("rnstatus -j 2>/dev/null | python3 -c " - "'import sys,json; print(len(json.load(sys.stdin).get(\"peers\",[])))'") -timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') +def _read_json(path): + """Read and parse JSON file.""" + # ... -# ─── Build Grid & Render ──────────────────────────────────── +def _get_field(name, default=""): + """Read submitted form field from environment.""" + return os.environ.get(f"FIELD_{name}", default) -grid = CharGrid(66, 40) +def _get_param(name, default=""): + """Read URL parameter.""" + return os.environ.get(f"PARAM_{name}", + os.environ.get(f"var_{name}", default)) -# ... all the box(), gauge(), sparkline(), text() calls -# ... exactly as the layout engine would produce them ... +def _load_state(path): + """Load state from JSON file.""" + # ... -# ─── Output ────────────────────────────────────────────────── +def _save_state(path, data): + """Save state to JSON file.""" + # ... -print('#!c=0') # cache header: never cache -print(grid.emit_micron()) +def _iter(val): + """Make a value iterable for for-loops.""" + # handles lists, dicts, newline-delimited strings + +# ─── µFrame Compile ────────────────────────────────────────── + +import uframe + +# ─── Page Logic ────────────────────────────────────────────── + +_cache_seconds = 0 + +_uf_source_parts = [] +cpu_pct = eval('secrets.randbelow(60) + 20', {'datetime': _dt_cls, ...}) +timestamp = eval("datetime.now().strftime('%H:%M:%S')", {'datetime': _dt_cls, ...}) + +_uf_source_parts.append(f'heading 1 "Resources"') +_uf_source_parts.append(f'gauge "CPU" {cpu_pct} 100 28 warn=75.0 crit=90.0') +_uf_source_parts.append(f'text "Updated: {timestamp}"') + +if cpu_pct > 90: + _uf_source_parts.append(f'text "ALERT: CPU critical"') + +# ─── Render & Output ───────────────────────────────────────── + +_uf_source = f'''page "Live Status" 60 +''' + "\n".join(_uf_source_parts) + +result = uframe.compile(_uf_source, width=60) + +if _cache_seconds >= 0: + print(f"#!c={_cache_seconds}") +print(result.micron) ``` +The key insight: the generated script **rebuilds `.uf` source** with +live data substituted in, then compiles it with the full µFrame +pipeline. This means every layout feature (boxes, gauges, tables, +sparklines) works identically in both static and dynamic pages. + ### 4.2 CLI usage ```bash @@ -644,30 +606,35 @@ sparkline renders identically in both ASCII preview and live Micron. page "Status" 64 cache 0 - source cpu : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'" - source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'" - source net_in : shell "net_traffic.sh in" - source net_out : shell "net_traffic.sh out" - source net_history_in : shell "net_spark.sh in 20" - source net_history_out : shell "net_spark.sh out 20" + source cpu : python "secrets.randbelow(60) + 20" + source mem : python "secrets.randbelow(40) + 50" + source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))" + source timestamp : python "datetime.now().strftime('%H:%M:%S')" box heavy "System Status" row 2 gauge "CPU" $cpu 100 28 warn=75 crit=90 gauge "MEM" $mem 100 28 warn=80 crit=95 spacer - label "IN" "$net_in KB/s" - sparkline "IN" $net_history_in 28 - label "OUT" "$net_out KB/s" - sparkline "OUT" $net_history_out 28 + label "Uptime" "$uptime" + label "Updated" "$timestamp" text "@center{@italic{Press Ctrl+R to refresh}}" ``` -Client hits the page → script runs → reads `/proc` → renders -gauges and sparklines with real data → client sees it. +Client hits the page → script runs → evaluates sources → +renders gauges with live data → client sees it. Ctrl+R re-requests → fresh execution → updated values. +On a full Linux node, replace the python sources with shell commands +to read real system data: + +``` +source cpu : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" +source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'" +source uptime : shell "uptime -p" +``` + ### 6.2 Guestbook with Persistent State ``` diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 76f0c93..e9a24f1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -160,6 +160,59 @@ export async function fetchRemotePage( return res.json(); } +// --------------------------------------------------------------------------- +// File Browser +// --------------------------------------------------------------------------- + +export interface FileEntry { + name: string; + path: string; + type: "file" | "folder" | "env"; + size: number | null; + last_modified: number | null; + title: string | null; + published: boolean; +} + +export async function fetchFiles(path: string = ""): Promise { + const params = path ? `?path=${encodeURIComponent(path)}` : ""; + const res = await fetch(`/api/files${params}`); + return res.json(); +} + +export async function createFolder(path: string): Promise { + const res = await fetch("/api/files/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!res.ok) throw new Error(await res.text()); +} + +export async function moveFile(from: string, to: string): Promise { + const res = await fetch("/api/files/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from, to }), + }); + if (!res.ok) throw new Error(await res.text()); +} + +export async function fetchEnv(): Promise { + const res = await fetch("/api/files/env"); + const data = await res.json(); + return data.content; +} + +export async function saveEnv(content: string): Promise { + const res = await fetch("/api/files/env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + if (!res.ok) throw new Error(await res.text()); +} + // --------------------------------------------------------------------------- // Images // --------------------------------------------------------------------------- diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts index 8dcb44d..2bbe89c 100644 --- a/frontend/src/components/editor/examples.ts +++ b/frontend/src/components/editor/examples.ts @@ -410,9 +410,9 @@ export const EXAMPLES: Example[] = [ source: `page "Live Status" 60 cache 0 - source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" - source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'" - source uptime : shell "uptime -p" + source cpu_pct : python "secrets.randbelow(60) + 20" + source mem_pct : python "secrets.randbelow(40) + 50" + source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))" source timestamp : python "datetime.now().strftime('%H:%M:%S')" box double "Node Monitor" diff --git a/frontend/src/components/editor/micronRenderer.ts b/frontend/src/components/editor/micronRenderer.ts index 5bba110..f1a1846 100644 --- a/frontend/src/components/editor/micronRenderer.ts +++ b/frontend/src/components/editor/micronRenderer.ts @@ -1,6 +1,9 @@ /** * Micron markup → HTML renderer using the micron-parser library. * Reference: https://github.com/RFnexus/micron-parser-js + * + * Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml + * to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags. */ import MicronParser from "micron-parser"; @@ -9,11 +12,14 @@ let darkParser: MicronParser | null = null; let lightParser: MicronParser | null = null; export function renderMicron(source: string, darkTheme: boolean = true): string { - if (darkTheme) { - if (!darkParser) darkParser = new MicronParser(true, true); - return darkParser.convertMicronToHtml(source); - } else { - if (!lightParser) lightParser = new MicronParser(false, true); - return lightParser.convertMicronToHtml(source); - } + const parser = darkTheme + ? (darkParser ??= new MicronParser(true, true)) + : (lightParser ??= new MicronParser(false, true)); + + const fragment = parser.convertMicronToFragment(source); + + // Serialize the fragment to HTML string + const div = document.createElement("div"); + div.appendChild(fragment); + return div.innerHTML; } diff --git a/frontend/src/components/shared/AppShell.tsx b/frontend/src/components/shared/AppShell.tsx index 5d71158..509e31f 100644 --- a/frontend/src/components/shared/AppShell.tsx +++ b/frontend/src/components/shared/AppShell.tsx @@ -101,7 +101,7 @@ const frameLayout: FrameLayout = { containerMaxW: "max-w-5xl", containerMinW: "min-w-5xl", title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }, - content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 }, + content: { marginLeft: 268, marginTop: 63, marginRight: 0, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 }, nav: { top: 150, left: -210 }, }; diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index 6ac288f..eb99b69 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -178,11 +178,20 @@ function buildGraphArrays( // Browse window data // --------------------------------------------------------------------------- +interface HistoryEntry { + path: string; + html: string | null; + error: string | null; +} + interface BrowseWinData { node: NetworkNode; pageHtml: string | null; pageLoading: boolean; pageError: string | null; + currentPath: string; + history: HistoryEntry[]; + historyIndex: number; } // --------------------------------------------------------------------------- @@ -485,15 +494,83 @@ export default function BrowseView() { return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); }; }, []); + // ── Navigation helpers ── + const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => { + const loading: BrowseWinData = { + node, pageHtml: null, pageLoading: true, pageError: null, + currentPath: path, + history: prevData?.history ?? [], + historyIndex: prevData?.historyIndex ?? -1, + }; + updateWindow(winId, { data: loading }); + + fetchRemotePage(node.hash, path) + .then((res) => { + const html = res.content ? renderMicron(res.content, true) : null; + const error = res.content ? null : (res.error ?? "No content"); + const entry: HistoryEntry = { path, html, error }; + + // Build new history: truncate any forward entries, push new + const prevHistory = loading.history.slice(0, loading.historyIndex + 1); + const newHistory = [...prevHistory, entry]; + const newIndex = newHistory.length - 1; + + updateWindow(winId, { data: { node, pageHtml: html, pageError: error, pageLoading: false, currentPath: path, history: newHistory, historyIndex: newIndex } }); + }) + .catch((e) => { + const error = String(e); + const entry: HistoryEntry = { path, html: null, error }; + const prevHistory = loading.history.slice(0, loading.historyIndex + 1); + const newHistory = [...prevHistory, entry]; + const newIndex = newHistory.length - 1; + updateWindow(winId, { data: { node, pageError: error, pageLoading: false, pageHtml: null, currentPath: path, history: newHistory, historyIndex: newIndex } }); + }); + }, [updateWindow]); + + const navBack = useCallback((winId: string, data: BrowseWinData) => { + const newIndex = data.historyIndex - 1; + if (newIndex < 0) return; + const entry = data.history[newIndex]!; + updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } }); + }, [updateWindow]); + + const navForward = useCallback((winId: string, data: BrowseWinData) => { + const newIndex = data.historyIndex + 1; + if (newIndex >= data.history.length) return; + const entry = data.history[newIndex]!; + updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } }); + }, [updateWindow]); + + const navReload = useCallback((winId: string, data: BrowseWinData) => { + navigateTo(winId, data.node, data.currentPath, { ...data, historyIndex: data.historyIndex - 1 }); + }, [navigateTo]); + // ── Node click ── const handleNodeClick = useCallback((node: NetworkNode) => { const id = node.hash; - const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null }; - openWindow(id, data); - fetchRemotePage(node.hash) - .then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } })) - .catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } })); - }, [openWindow, updateWindow]); + const initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 }; + openWindow(id, initData); + navigateTo(id, node, "index.mu"); + }, [openWindow, navigateTo]); + + // ── Handle micron link clicks via event delegation ── + const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => { + const anchor = (e.target as HTMLElement).closest("a"); + if (!anchor) return; + e.preventDefault(); + + const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? ""; + if (!dest) return; + + // Strip nomadnetwork:// prefix if present, normalize path + let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, ""); + // If it looks like a hash (hex, 32 chars), it's a node link — not a page path + if (/^[0-9a-f]{32}$/i.test(path)) return; + // Ensure .mu extension + if (!path.endsWith(".mu")) path += ".mu"; + + navigateTo(winId, data.node, path, data); + }, [navigateTo]); const clearSearch = useCallback(() => { setFilter(""); @@ -502,8 +579,6 @@ export default function BrowseView() { updateClusterLabels(); }, [updateClusterLabels]); - const nodeCount = nodes.filter(n => n.type !== "interface").length; - const ifaceCount = nodes.filter(n => n.type === "interface").length; // ── Capture typing into search when no window is focused ── useEffect(() => { searchInputRef.current?.focus(); }, []); @@ -612,42 +687,63 @@ export default function BrowseView() { )} - {windows.map((win) => ( - - addr -
{win.data.node.hash}
- - {win.data.pageLoading ? loading - : win.data.pageError ? <>error - : win.data.pageHtml ? <>ok : null} - + {windows.map((win) => { + const d = win.data; + const canBack = d.historyIndex > 0; + const canFwd = d.historyIndex < d.history.length - 1; + return ( + + {/* Nav buttons */} + + + + {/* Address */} +
+ {d.node.hash.slice(0, 12)}…/{d.currentPath} +
+ + {d.pageLoading ? loading + : d.pageError ? <>error + : d.pageHtml ? <>ok : null} + + + } + footer={ +
+ {d.node.type ?? "peer"} + {d.node.interface && via {d.node.interface}} +
+ } + > + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
handleContentClick(e, win.id, d)}> + {d.pageLoading && Requesting page...} + {d.pageError && {d.pageError}} + {d.pageHtml &&
}
- } - footer={ -
- {win.data.node.type ?? "peer"} - {win.data.node.interface && via {win.data.node.interface}} -
- } - > -
- {win.data.pageLoading && Requesting page...} - {win.data.pageError && {win.data.pageError}} - {win.data.pageHtml &&
} -
- - ))} + + ); + })}
); } diff --git a/frontend/src/routes/ComposeView.tsx b/frontend/src/routes/ComposeView.tsx index da1b6e5..4ba33b4 100644 --- a/frontend/src/routes/ComposeView.tsx +++ b/frontend/src/routes/ComposeView.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { MoreVertical, Plus, RotateCcw } from "lucide-react"; +import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react"; import { usePagesStore } from "@/stores/pagesStore"; -import { restartNode } from "@/api/client"; +import * as api from "@/api/client"; import StatusBadge from "@/components/dashboard/StatusBadge"; import { Button } from "@/components/ui/button"; import { @@ -30,39 +30,104 @@ import { } from "@/components/ui/alert-dialog"; import { useWindowManager } from "@/hooks/useWindowManager"; import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow"; +import EditorPane from "@/components/editor/EditorPane"; +import EditorPointer from "@/components/editor/EditorPointer"; +import FloatingWindow from "@/components/shared/FloatingWindow"; +import type { ManagedWindow } from "@/hooks/useWindowManager"; + +// --------------------------------------------------------------------------- +// Env editor window data +// --------------------------------------------------------------------------- + +interface EnvWinData { + kind: "env"; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- export default function ComposeView() { - const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } = - usePagesStore(); + const { deletePage, publishPage, unpublishPage } = usePagesStore(); + + // File browser state + const [currentPath, setCurrentPath] = useState(""); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [pageToDelete, setPageToDelete] = useState(null); - const [restarting, setRestarting] = useState(false); - const { windows, focusedId, open, update, close, focus } = useWindowManager({ w: 720, h: 520 }); + const [newFolderName, setNewFolderName] = useState(""); + const [showNewFolder, setShowNewFolder] = useState(false); + const [sortKey, setSortKey] = useState<"name" | "size" | "modified">("name"); + const [sortAsc, setSortAsc] = useState(true); + + const sortedFiles = useMemo(() => { + // Folders always first, then sort within each group + const folders = files.filter(f => f.type === "folder"); + const rest = files.filter(f => f.type !== "folder"); + const cmp = (a: api.FileEntry, b: api.FileEntry): number => { + let v = 0; + if (sortKey === "name") v = a.name.localeCompare(b.name); + else if (sortKey === "size") v = (a.size ?? 0) - (b.size ?? 0); + else if (sortKey === "modified") v = (a.last_modified ?? 0) - (b.last_modified ?? 0); + return sortAsc ? v : -v; + }; + folders.sort(cmp); + rest.sort(cmp); + return [...folders, ...rest]; + }, [files, sortKey, sortAsc]); + + const toggleSort = (key: "name" | "size" | "modified") => { + if (sortKey === key) setSortAsc(!sortAsc); + else { setSortKey(key); setSortAsc(true); } + }; + + // Editor windows + const { windows: editorWindows, focusedId: editorFocused, open: openEditorWin, update: updateEditorWin, close: closeEditorWin, focus: focusEditorWin } = useWindowManager({ w: 720, h: 520 }); + + // Env editor windows + const { windows: envWindows, focusedId: envFocused, open: openEnvWin, update: updateEnvWin, close: closeEnvWin, focus: focusEnvWin } = useWindowManager({ w: 520, h: 400 }); + + const loadFiles = useCallback(async (path: string = currentPath) => { + setIsLoading(true); + try { + const entries = await api.fetchFiles(path); + setFiles(entries); + } finally { + setIsLoading(false); + } + }, [currentPath]); + + useEffect(() => { loadFiles(currentPath); }, [currentPath]); + + const navigateTo = (path: string) => setCurrentPath(path); + + const navigateUp = () => { + if (!currentPath) return; + const parts = currentPath.split("/").filter(Boolean); + parts.pop(); + setCurrentPath(parts.join("/")); + }; + + // Path breadcrumbs + const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : []; const openEditor = (name: string, isNew: boolean) => { - const id = isNew ? `new-${Date.now()}` : name; - open(id, { pageName: isNew ? "" : name, isNew }); + // For files in subfolders, use full relative path as page name + const pageName = isNew ? "" : name; + const id = isNew ? `new-${Date.now()}` : pageName; + openEditorWin(id, { pageName, isNew }); }; - useEffect(() => { - fetchPages(); - }, []); - - const handleRestart = async () => { - setRestarting(true); - try { - await restartNode(); - toast.success("NomadNet restarted"); - } catch (e) { - toast.error(`Restart failed: ${e}`); - } finally { - setRestarting(false); - } + const openEnvEditor = () => { + openEnvWin("env-editor", { kind: "env" }); }; + const handlePublish = async (name: string) => { try { await publishPage(name); toast.success(`"${name}" published`); + loadFiles(); } catch (e) { toast.error(`Failed: ${e}`); } @@ -72,6 +137,7 @@ export default function ComposeView() { try { await unpublishPage(name); toast.success(`"${name}" unpublished`); + loadFiles(); } catch (e) { toast.error(`Failed: ${e}`); } @@ -79,28 +145,63 @@ export default function ComposeView() { const handleDelete = async () => { if (!pageToDelete) return; - await deletePage(pageToDelete); + // Extract stem from path for the pages API + const stem = pageToDelete.replace(/\.uf$/, ""); + await deletePage(stem); toast.success(`"${pageToDelete}" deleted`); setPageToDelete(null); + loadFiles(); }; - if (isLoading) - return ( -
- Loading... -
- ); + const handleCreateFolder = async () => { + const name = newFolderName.trim(); + if (!name) return; + const folderPath = currentPath ? `${currentPath}/${name}` : name; + try { + await api.createFolder(folderPath); + toast.success(`Folder "${name}" created`); + setNewFolderName(""); + setShowNewFolder(false); + loadFiles(); + } catch (e) { + toast.error(`Failed: ${e}`); + } + }; + + const handleFileClick = (entry: api.FileEntry) => { + if (entry.type === "folder") { + navigateTo(entry.path); + } else if (entry.type === "env") { + openEnvEditor(); + } else { + // Open .uf file in editor — strip .uf extension for page name + const pageName = entry.path.replace(/\.uf$/, ""); + openEditor(pageName, false); + } + }; + + const formatSize = (size: number | null) => { + if (size == null) return "\u2014"; + if (size < 1024) return `${size} B`; + return `${(size / 1024).toFixed(1)} KB`; + }; + + const formatTime = (ts: number | null) => { + if (ts == null) return "\u2014"; + const d = new Date(ts * 1000); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + }; return ( -
-
+
+
{/* Header row */}

Compose

-
- {/* Table */} - - - - Name - Title - Status - Size - - - - - {pages.map((p) => ( - openEditor(p.name, false)} - > - - {p.name} - {p.name === "index" && ( - homepage - )} - - - {p.title ?? "\u2014"} - - - - - - {p.size != null ? `${p.size} B` : "\u2014"} - - - openEditor(p.name, false)} - onPublish={() => handlePublish(p.name)} - onUnpublish={() => handleUnpublish(p.name)} - onDelete={() => setPageToDelete(p.name)} - /> - - - ))} - {pages.length === 0 && ( + {/* Path bar */} +
+ {currentPath && ( + + )} + + {pathParts.map((part, i) => { + const partPath = pathParts.slice(0, i + 1).join("/"); + return ( + + + + + ); + })} + + {/* New folder inline input */} + {showNewFolder && ( + + + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateFolder(); + if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } + }} + placeholder="folder name" + className="h-5 px-1.5 text-xs bg-background border border-border rounded font-mono w-32 focus:outline-none focus:ring-1 focus:ring-primary" + /> + + + + )} +
+ + {/* File table */} +
+ {isLoading ? ( +
Loading...
+ ) : ( +
+ - - No pages yet. Create one to get started. - + + Status + + + - )} - -
+ + + {sortedFiles.map((entry) => ( + handleFileClick(entry)} + > + + + {entry.type === "folder" ? ( + + ) : entry.type === "env" ? ( + + ) : ( + + )} + {entry.name} + {entry.name === "index.uf" && ( + homepage + )} + + + + {entry.type === "file" && entry.name.endsWith(".uf") ? ( + + ) : null} + + + {entry.type !== "folder" ? formatSize(entry.size) : "\u2014"} + + + {formatTime(entry.last_modified)} + + + {entry.type === "file" && entry.name.endsWith(".uf") && ( + f.type === "folder")} + currentPath={currentPath} + onEdit={() => handleFileClick(entry)} + onPublish={() => handlePublish(entry.path.replace(/\.uf$/, ""))} + onUnpublish={() => handleUnpublish(entry.path.replace(/\.uf$/, ""))} + onDelete={() => setPageToDelete(entry.path)} + onMove={async (to) => { + try { + await api.moveFile(entry.path, to); + toast.success(`Moved "${entry.name}" to ${to || "/"}`); + loadFiles(); + } catch (e) { + toast.error(`Move failed: ${e}`); + } + }} + /> + )} + + + ))} + {files.length === 0 && ( + + + {currentPath ? "Empty folder." : "No files yet. Create a page to get started."} + + + )} + + + )} +
+ {/* Delete confirmation */} !open && setPageToDelete(null)} @@ -175,8 +347,7 @@ export default function ComposeView() { Delete "{pageToDelete}"? - This permanently deletes the page and its source. This cannot be - undone. + This permanently deletes the file and its published version. This cannot be undone. @@ -191,15 +362,27 @@ export default function ComposeView() { - {/* Floating editor windows */} - {windows.map((win) => ( + {/* Editor windows */} + {editorWindows.map((win) => ( + ))} + + {/* Env editor windows */} + {envWindows.map((win) => ( + ))}
@@ -207,20 +390,40 @@ export default function ComposeView() { } -/** Per-row action menu for a page. */ -function PageActions({ - published, +// --------------------------------------------------------------------------- +// File action menu +// --------------------------------------------------------------------------- + +function FileActions({ + entry, + folders, + currentPath, onEdit, onPublish, onUnpublish, onDelete, + onMove, }: { - published: boolean; + entry: api.FileEntry; + folders: api.FileEntry[]; + currentPath: string; onEdit: () => void; onPublish: () => void; onUnpublish: () => void; onDelete: () => void; + onMove: (to: string) => void; }) { + const [showMove, setShowMove] = useState(false); + + // Build move targets: parent dir (if in a subfolder) + sibling folders + const moveTargets: { label: string; path: string }[] = []; + if (currentPath) { + moveTargets.push({ label: "/ (root)", path: entry.name }); + } + for (const f of folders) { + moveTargets.push({ label: f.name + "/", path: f.path + "/" + entry.name }); + } + return ( } /> - + - {published ? ( + {entry.published ? ( )} + {moveTargets.length > 0 && ( + <> + + {showMove && ( +
+ {moveTargets.map((t) => ( + + ))} +
+ )} + + )} + + ); +} + + +// --------------------------------------------------------------------------- +// Env editor floating window +// --------------------------------------------------------------------------- + +function EnvEditorWindow({ + win, + focused, + onUpdate, + onClose, + onFocus, +}: { + win: ManagedWindow; + focused: boolean; + onUpdate: (id: string, patch: Partial>) => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; +}) { + const windowRef = useRef(null); + const [content, setContent] = useState(""); + const [isDirty, setIsDirty] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.fetchEnv().then((c) => setContent(c)); + }, []); + + const handleChange = useCallback((v: string) => { + setContent(v); + setIsDirty(true); + }, []); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + await api.saveEnv(content); + setIsDirty(false); + toast.success(".env saved"); + } catch (e) { + toast.error(`Save failed: ${e}`); + } finally { + setSaving(false); + } + }, [content]); + + useEffect(() => { + if (!focused) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + handleSave(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [focused, handleSave]); + + const handleClose = useCallback((id: string) => { + if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return; + onClose(id); + }, [isDirty, onClose]); + + return ( + + +
+ {/* Toolbar */} +
+ + Environment Variables + +
+ {/* Help */} +
+ One variable per line: KEY=value. Use source name : env "KEY" in pages. +
+ {/* CodeMirror editor */} +
+ +
+
+
+ ); +}