feat: composer improvements
This commit is contained in:
151
CLAUDE.md
151
CLAUDE.md
@@ -205,27 +205,154 @@ form "name"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Dynamic Features
|
### 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
|
if $cpu > 90
|
||||||
text "ALERT: CPU critical"
|
text "ALERT: CPU critical"
|
||||||
elif $cpu > 75
|
elif $cpu > 75
|
||||||
text "Warning: elevated"
|
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
|
for peer in $peers
|
||||||
status "$peer.name" $peer.state
|
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
|
### Components
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -264,12 +264,19 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _read_local_page(path: str) -> dict:
|
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"))
|
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
|
||||||
filepath = Path(pages_dir) / path
|
filepath = Path(pages_dir) / path
|
||||||
try:
|
if not filepath.exists():
|
||||||
return {"content": filepath.read_text()}
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {"content": None, "error": "Page not found"}
|
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)}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""µFrame compile, DSL metadata, and image upload endpoints."""
|
"""µFrame compile, DSL metadata, and image upload endpoints."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||||
@@ -14,6 +17,31 @@ from uframe.registry import get_dsl_meta
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
|
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):
|
class CompileRequest(BaseModel):
|
||||||
@@ -31,12 +59,26 @@ class CompileResponse(BaseModel):
|
|||||||
|
|
||||||
@router.post("/compile", response_model=CompileResponse)
|
@router.post("/compile", response_model=CompileResponse)
|
||||||
async def compile_source(req: CompileRequest):
|
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:
|
try:
|
||||||
result = uframe.compile(req.source, width=req.width)
|
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(
|
return CompileResponse(
|
||||||
ascii=result.ascii,
|
ascii=result.ascii,
|
||||||
micron=result.micron,
|
micron=micron,
|
||||||
script=result.script,
|
script=result.script,
|
||||||
is_dynamic=result.is_dynamic,
|
is_dynamic=result.is_dynamic,
|
||||||
warnings=[w.message for w in result.warnings],
|
warnings=[w.message for w in result.warnings],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 docker_utils import router as docker_router
|
||||||
from converter import router as converter_router
|
from converter import router as converter_router
|
||||||
from browse import router as browse_router, start_browser
|
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(converter_router, prefix="/api")
|
||||||
app.include_router(pages_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(docker_router, prefix="/api")
|
||||||
app.include_router(browse_router, prefix="/api")
|
app.include_router(browse_router, prefix="/api")
|
||||||
|
|
||||||
|
|||||||
192
backend/pages.py
192
backend/pages.py
@@ -1,13 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
import uframe
|
import uframe
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
files_router = APIRouter()
|
||||||
|
|
||||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||||
@@ -38,7 +40,7 @@ page "Welcome" 60
|
|||||||
|
|
||||||
|
|
||||||
def ensure_default_pages():
|
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)
|
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
SOURCES_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():
|
if not index_src.is_file():
|
||||||
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
|
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):
|
class PageMeta(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -225,3 +238,176 @@ async def delete_page(name: str):
|
|||||||
mu_path.unlink()
|
mu_path.unlink()
|
||||||
|
|
||||||
return {"deleted": name}
|
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}
|
||||||
|
|||||||
@@ -29,14 +29,56 @@ def _indent(code: str, level: int = 1) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_vars(text: str) -> 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
|
import re
|
||||||
# Replace $var.attr.attr with {var_attr_attr} and simple $var with {var}
|
|
||||||
def replace_var(m: re.Match) -> str:
|
def replace_var(m: re.Match) -> str:
|
||||||
var = m.group(1)
|
var = m.group(1)
|
||||||
# Replace dots with underscores for Python variable names
|
if "." in var:
|
||||||
py_var = var.replace(".", "_")
|
parts = var.split(".")
|
||||||
return "{" + py_var + "}"
|
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)
|
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:
|
elif node.source_type == SourceType.JSON:
|
||||||
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
||||||
elif node.source_type == SourceType.PYTHON:
|
elif node.source_type == SourceType.PYTHON:
|
||||||
# Restricted eval — only datetime/secrets modules available
|
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})")
|
||||||
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})")
|
|
||||||
elif node.source_type == SourceType.PARAM:
|
elif node.source_type == SourceType.PARAM:
|
||||||
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
||||||
elif node.source_type == SourceType.RNS:
|
elif node.source_type == SourceType.RNS:
|
||||||
import shlex as _shlex
|
import shlex as _shlex
|
||||||
safe_cmd = _shlex.quote(node.command)
|
safe_cmd = _shlex.quote(node.command)
|
||||||
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
|
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):
|
elif isinstance(node, CacheControl):
|
||||||
lines.append(f"{ind}_cache_seconds = {node.seconds}")
|
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})")
|
lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})")
|
||||||
|
|
||||||
elif isinstance(node, IfBlock):
|
elif isinstance(node, IfBlock):
|
||||||
cond = _resolve_vars(node.condition)
|
cond = _resolve_vars_code(node.condition)
|
||||||
# Convert simple comparisons
|
|
||||||
cond = cond.replace("&&", " and ").replace("||", " or ")
|
cond = cond.replace("&&", " and ").replace("||", " or ")
|
||||||
lines.append(f"{ind}if {cond}:")
|
lines.append(f"{ind}if {cond}:")
|
||||||
if node.children:
|
if node.children:
|
||||||
@@ -94,7 +149,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
|||||||
lines.append(f"{ind} pass")
|
lines.append(f"{ind} pass")
|
||||||
|
|
||||||
for elif_cond, elif_children in node.elif_branches:
|
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}:")
|
lines.append(f"{ind}elif {ec}:")
|
||||||
if elif_children:
|
if elif_children:
|
||||||
for child in 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))
|
lines.extend(_emit_node(child, indent_level + 1))
|
||||||
|
|
||||||
elif isinstance(node, ForLoop):
|
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}):")
|
lines.append(f"{ind}for {node.var_name} in _iter({iterable}):")
|
||||||
if node.children:
|
if node.children:
|
||||||
for child in 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):
|
elif isinstance(node, Gauge):
|
||||||
label = _resolve_vars(node.label)
|
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 = ""
|
extra = ""
|
||||||
if node.warn is not None:
|
if node.warn is not None:
|
||||||
extra += f" warn={node.warn}"
|
extra += f" warn={node.warn}"
|
||||||
if node.crit is not None:
|
if node.crit is not None:
|
||||||
extra += f" crit={node.crit}"
|
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):
|
elif isinstance(node, Status):
|
||||||
label = _resolve_vars(node.label)
|
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 <source>.uf",
|
"# Do not edit — regenerate with: uframe compile <source>.uf",
|
||||||
"",
|
"",
|
||||||
"import os, sys, json, subprocess, datetime, secrets, shlex",
|
"import os, sys, json, subprocess, datetime, secrets, shlex",
|
||||||
|
"from datetime import datetime as _dt_cls, timedelta",
|
||||||
"",
|
"",
|
||||||
"# ─── Runtime Helpers ─────────────────────────────────────────",
|
"# ─── 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 val.strip().splitlines()',
|
||||||
' return []',
|
' 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 ──────────────────────────────────────────",
|
f"# ─── µFrame Compile ──────────────────────────────────────────",
|
||||||
"",
|
"",
|
||||||
uframe_import,
|
uframe_import,
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
|||||||
if cur_style != _EMPTY_STYLE:
|
if cur_style != _EMPTY_STYLE:
|
||||||
line_parts.append(_emit_style_close(cur_style))
|
line_parts.append(_emit_style_close(cur_style))
|
||||||
cur_style = _EMPTY_STYLE
|
cur_style = _EMPTY_STYLE
|
||||||
# Open new link
|
# Open new link — backtick enters formatting mode
|
||||||
line_parts.append("[")
|
# where the parser recognizes `[` as link start
|
||||||
|
line_parts.append("`[")
|
||||||
in_link = link
|
in_link = link
|
||||||
|
|
||||||
# Handle style transitions (not inside links — links handle their own style)
|
# Handle style transitions (not inside links — links handle their own style)
|
||||||
|
|||||||
@@ -302,6 +302,9 @@ class SourceType(Enum):
|
|||||||
PYTHON = auto()
|
PYTHON = auto()
|
||||||
RNS = auto()
|
RNS = auto()
|
||||||
PARAM = auto()
|
PARAM = auto()
|
||||||
|
HTTP = auto()
|
||||||
|
SQLITE = auto()
|
||||||
|
ENV = auto()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -317,6 +320,12 @@ class Source(IRNode):
|
|||||||
var_name: str = ""
|
var_name: str = ""
|
||||||
source_type: SourceType = SourceType.SHELL
|
source_type: SourceType = SourceType.SHELL
|
||||||
command: str = ""
|
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
|
timeout: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ def _parse_list_style(s: str) -> ListStyle:
|
|||||||
}.get(s.lower(), ListStyle.BULLET)
|
}.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."""
|
"""Parse a single line into an IR node based on the keyword."""
|
||||||
|
|
||||||
if keyword == "page":
|
if keyword == "page":
|
||||||
@@ -364,7 +364,7 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
|||||||
# Dynamic features
|
# Dynamic features
|
||||||
elif keyword == "let":
|
elif keyword == "let":
|
||||||
# let name = "value" or let name = 1,2,3
|
# let name = "value" or let name = 1,2,3
|
||||||
raw = " ".join(args)
|
raw = raw_args
|
||||||
eq = raw.find("=")
|
eq = raw.find("=")
|
||||||
if eq != -1:
|
if eq != -1:
|
||||||
var_name = raw[:eq].strip()
|
var_name = raw[:eq].strip()
|
||||||
@@ -377,11 +377,10 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
|||||||
elif keyword == "source":
|
elif keyword == "source":
|
||||||
# source cpu : shell "grep 'cpu' /proc/stat"
|
# source cpu : shell "grep 'cpu' /proc/stat"
|
||||||
# source name : type "command"
|
# source name : type "command"
|
||||||
raw = " ".join(args)
|
colon = raw_args.find(":")
|
||||||
colon = raw.find(":")
|
|
||||||
if colon != -1:
|
if colon != -1:
|
||||||
var_name = raw[:colon].strip()
|
var_name = raw_args[:colon].strip()
|
||||||
rest = raw[colon + 1:].strip()
|
rest = raw_args[colon + 1:].strip()
|
||||||
parts = _split_args(rest)
|
parts = _split_args(rest)
|
||||||
src_type_str = parts[0] if parts else "shell"
|
src_type_str = parts[0] if parts else "shell"
|
||||||
command = parts[1] if len(parts) > 1 else ""
|
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,
|
"python": SourceType.PYTHON,
|
||||||
"rns": SourceType.RNS,
|
"rns": SourceType.RNS,
|
||||||
"param": SourceType.PARAM,
|
"param": SourceType.PARAM,
|
||||||
|
"http": SourceType.HTTP,
|
||||||
|
"sqlite": SourceType.SQLITE,
|
||||||
|
"env": SourceType.ENV,
|
||||||
}.get(src_type_str.lower(), SourceType.SHELL)
|
}.get(src_type_str.lower(), SourceType.SHELL)
|
||||||
# Parse optional timeout
|
# Parse optional params from remaining parts
|
||||||
timeout = 5
|
timeout = 5
|
||||||
for p in parts[2:]:
|
http_method = "GET"
|
||||||
if p.startswith("timeout"):
|
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:
|
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):
|
except (ValueError, IndexError):
|
||||||
pass
|
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,
|
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:
|
else:
|
||||||
return Source(var_name=args[0] if args else "", source_line=line_num)
|
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)
|
args = _split_args(arg_str)
|
||||||
|
|
||||||
# Parse this line into a node
|
# 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)
|
# Pop stack back to find the parent (parent indent < this indent)
|
||||||
while stack and stack[-1][0] >= indent:
|
while stack and stack[-1][0] >= indent:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def test_if_block():
|
|||||||
text "Critical"'''
|
text "Critical"'''
|
||||||
result = uframe.compile(source)
|
result = uframe.compile(source)
|
||||||
assert result.is_dynamic
|
assert result.is_dynamic
|
||||||
assert "if {val} > 90:" in result.script
|
assert "if val > 90:" in result.script
|
||||||
|
|
||||||
|
|
||||||
def test_for_loop():
|
def test_for_loop():
|
||||||
@@ -46,7 +46,7 @@ def test_for_loop():
|
|||||||
text "$item"'''
|
text "$item"'''
|
||||||
result = uframe.compile(source)
|
result = uframe.compile(source)
|
||||||
assert result.is_dynamic
|
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():
|
def test_let_variable():
|
||||||
@@ -120,5 +120,5 @@ def test_codegen_complete_dashboard():
|
|||||||
assert "#!/usr/bin/env python3" in script
|
assert "#!/usr/bin/env python3" in script
|
||||||
assert "_cache_seconds = 0" in script
|
assert "_cache_seconds = 0" in script
|
||||||
assert "_shell" in script
|
assert "_shell" in script
|
||||||
assert "if {cpu} > 90:" in script
|
assert "if cpu > 90:" in script
|
||||||
assert "uframe.compile" in script
|
assert "uframe.compile" in script
|
||||||
|
|||||||
@@ -158,9 +158,11 @@ source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(s
|
|||||||
source motd : file "/etc/motd"
|
source motd : file "/etc/motd"
|
||||||
source config : json "/home/node/.nomadnetwork/config.json"
|
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 timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
|
||||||
source rand_hex : python "secrets.token_hex(4)"
|
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
|
# RNS/Reticulum API — direct integration
|
||||||
source peer_list : rns "peers"
|
source peer_list : rns "peers"
|
||||||
@@ -346,135 +348,95 @@ A `.uf` file with dynamic features compiles into a Python script
|
|||||||
that:
|
that:
|
||||||
|
|
||||||
1. Sets the shebang and cache header
|
1. Sets the shebang and cache header
|
||||||
2. Imports required modules
|
2. Imports required modules + the `uframe` package
|
||||||
3. Reads environment variables (form data)
|
3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
|
||||||
4. Executes source commands (shell, file, python, rns)
|
4. Executes source commands and evaluates conditionals/loops
|
||||||
5. Evaluates conditionals and loops
|
5. Dynamically builds a `.uf` source string with resolved variables
|
||||||
6. Renders the IR tree into a CharGrid
|
6. Compiles that source with `uframe.compile()` at runtime
|
||||||
7. Emits the CharGrid as Micron with style tags
|
7. Prints the resulting Micron to stdout
|
||||||
8. Prints to stdout
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
#!c=0
|
# Auto-generated by uFrame
|
||||||
# Auto-generated by µFrame from dashboard.uf
|
# Do not edit — regenerate with: uframe compile <source>.uf
|
||||||
# Do not edit — regenerate with: uframe compile dashboard.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:
|
def _shell(cmd, timeout=5):
|
||||||
"""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):
|
|
||||||
"""Execute shell command, return stdout."""
|
"""Execute shell command, return stdout."""
|
||||||
try:
|
try:
|
||||||
return subprocess.check_output(
|
return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()
|
||||||
cmd, shell=True, timeout=5
|
|
||||||
).decode().strip()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
# ─── Resolve Sources ─────────────────────────────────────────
|
def _read_file(path):
|
||||||
|
"""Read file contents."""
|
||||||
|
# ...
|
||||||
|
|
||||||
cpu_pct = int(shell(
|
def _read_json(path):
|
||||||
"grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
"""Read and parse JSON file."""
|
||||||
) 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')
|
|
||||||
|
|
||||||
# ─── 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
|
def _load_state(path):
|
||||||
# ... exactly as the layout engine would produce them ...
|
"""Load state from JSON file."""
|
||||||
|
# ...
|
||||||
|
|
||||||
# ─── Output ──────────────────────────────────────────────────
|
def _save_state(path, data):
|
||||||
|
"""Save state to JSON file."""
|
||||||
|
# ...
|
||||||
|
|
||||||
print('#!c=0') # cache header: never cache
|
def _iter(val):
|
||||||
print(grid.emit_micron())
|
"""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
|
### 4.2 CLI usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -644,30 +606,35 @@ sparkline renders identically in both ASCII preview and live Micron.
|
|||||||
page "Status" 64
|
page "Status" 64
|
||||||
cache 0
|
cache 0
|
||||||
|
|
||||||
source cpu : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'"
|
source cpu : python "secrets.randbelow(60) + 20"
|
||||||
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
source mem : python "secrets.randbelow(40) + 50"
|
||||||
source net_in : shell "net_traffic.sh in"
|
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
|
||||||
source net_out : shell "net_traffic.sh out"
|
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||||
source net_history_in : shell "net_spark.sh in 20"
|
|
||||||
source net_history_out : shell "net_spark.sh out 20"
|
|
||||||
|
|
||||||
box heavy "System Status"
|
box heavy "System Status"
|
||||||
row 2
|
row 2
|
||||||
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
||||||
gauge "MEM" $mem 100 28 warn=80 crit=95
|
gauge "MEM" $mem 100 28 warn=80 crit=95
|
||||||
spacer
|
spacer
|
||||||
label "IN" "$net_in KB/s"
|
label "Uptime" "$uptime"
|
||||||
sparkline "IN" $net_history_in 28
|
label "Updated" "$timestamp"
|
||||||
label "OUT" "$net_out KB/s"
|
|
||||||
sparkline "OUT" $net_history_out 28
|
|
||||||
|
|
||||||
text "@center{@italic{Press Ctrl+R to refresh}}"
|
text "@center{@italic{Press Ctrl+R to refresh}}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Client hits the page → script runs → reads `/proc` → renders
|
Client hits the page → script runs → evaluates sources →
|
||||||
gauges and sparklines with real data → client sees it.
|
renders gauges with live data → client sees it.
|
||||||
Ctrl+R re-requests → fresh execution → updated values.
|
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
|
### 6.2 Guestbook with Persistent State
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -160,6 +160,59 @@ export async function fetchRemotePage(
|
|||||||
return res.json();
|
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<FileEntry[]> {
|
||||||
|
const params = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||||
|
const res = await fetch(`/api/files${params}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(path: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
const res = await fetch("/api/files/env");
|
||||||
|
const data = await res.json();
|
||||||
|
return data.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEnv(content: string): Promise<void> {
|
||||||
|
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
|
// Images
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -410,9 +410,9 @@ export const EXAMPLES: Example[] = [
|
|||||||
source: `page "Live Status" 60
|
source: `page "Live Status" 60
|
||||||
cache 0
|
cache 0
|
||||||
|
|
||||||
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
source cpu_pct : python "secrets.randbelow(60) + 20"
|
||||||
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
source mem_pct : python "secrets.randbelow(40) + 50"
|
||||||
source uptime : shell "uptime -p"
|
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
|
||||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||||
|
|
||||||
box double "Node Monitor"
|
box double "Node Monitor"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Micron markup → HTML renderer using the micron-parser library.
|
* Micron markup → HTML renderer using the micron-parser library.
|
||||||
* Reference: https://github.com/RFnexus/micron-parser-js
|
* 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";
|
import MicronParser from "micron-parser";
|
||||||
@@ -9,11 +12,14 @@ let darkParser: MicronParser | null = null;
|
|||||||
let lightParser: MicronParser | null = null;
|
let lightParser: MicronParser | null = null;
|
||||||
|
|
||||||
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
||||||
if (darkTheme) {
|
const parser = darkTheme
|
||||||
if (!darkParser) darkParser = new MicronParser(true, true);
|
? (darkParser ??= new MicronParser(true, true))
|
||||||
return darkParser.convertMicronToHtml(source);
|
: (lightParser ??= new MicronParser(false, true));
|
||||||
} else {
|
|
||||||
if (!lightParser) lightParser = new MicronParser(false, true);
|
const fragment = parser.convertMicronToFragment(source);
|
||||||
return lightParser.convertMicronToHtml(source);
|
|
||||||
}
|
// Serialize the fragment to HTML string
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.appendChild(fragment);
|
||||||
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const frameLayout: FrameLayout = {
|
|||||||
containerMaxW: "max-w-5xl",
|
containerMaxW: "max-w-5xl",
|
||||||
containerMinW: "min-w-5xl",
|
containerMinW: "min-w-5xl",
|
||||||
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
|
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 },
|
nav: { top: 150, left: -210 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -178,11 +178,20 @@ function buildGraphArrays(
|
|||||||
// Browse window data
|
// Browse window data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
path: string;
|
||||||
|
html: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface BrowseWinData {
|
interface BrowseWinData {
|
||||||
node: NetworkNode;
|
node: NetworkNode;
|
||||||
pageHtml: string | null;
|
pageHtml: string | null;
|
||||||
pageLoading: boolean;
|
pageLoading: boolean;
|
||||||
pageError: string | null;
|
pageError: string | null;
|
||||||
|
currentPath: string;
|
||||||
|
history: HistoryEntry[];
|
||||||
|
historyIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -485,15 +494,83 @@ export default function BrowseView() {
|
|||||||
return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
|
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 ──
|
// ── Node click ──
|
||||||
const handleNodeClick = useCallback((node: NetworkNode) => {
|
const handleNodeClick = useCallback((node: NetworkNode) => {
|
||||||
const id = node.hash;
|
const id = node.hash;
|
||||||
const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null };
|
const initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 };
|
||||||
openWindow(id, data);
|
openWindow(id, initData);
|
||||||
fetchRemotePage(node.hash)
|
navigateTo(id, node, "index.mu");
|
||||||
.then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } }))
|
}, [openWindow, navigateTo]);
|
||||||
.catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } }));
|
|
||||||
}, [openWindow, updateWindow]);
|
// ── 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(() => {
|
const clearSearch = useCallback(() => {
|
||||||
setFilter("");
|
setFilter("");
|
||||||
@@ -502,8 +579,6 @@ export default function BrowseView() {
|
|||||||
updateClusterLabels();
|
updateClusterLabels();
|
||||||
}, [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 ──
|
// ── Capture typing into search when no window is focused ──
|
||||||
useEffect(() => { searchInputRef.current?.focus(); }, []);
|
useEffect(() => { searchInputRef.current?.focus(); }, []);
|
||||||
@@ -612,42 +687,63 @@ export default function BrowseView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{windows.map((win) => (
|
{windows.map((win) => {
|
||||||
<FloatingWindow
|
const d = win.data;
|
||||||
key={win.id}
|
const canBack = d.historyIndex > 0;
|
||||||
id={win.id}
|
const canFwd = d.historyIndex < d.history.length - 1;
|
||||||
title={win.data.node.name}
|
return (
|
||||||
x={win.x} y={win.y} w={win.w} h={win.h}
|
<FloatingWindow
|
||||||
zIndex={win.zIndex}
|
key={win.id}
|
||||||
focused={focusedWinId === win.id}
|
id={win.id}
|
||||||
onUpdate={updateWindow}
|
title={win.data.node.name}
|
||||||
onClose={closeWindowById}
|
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||||
onFocus={focusWindow}
|
zIndex={win.zIndex}
|
||||||
addressBar={
|
focused={focusedWinId === win.id}
|
||||||
<div className="flex items-center gap-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15">
|
onUpdate={updateWindow}
|
||||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span>
|
onClose={closeWindowById}
|
||||||
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">{win.data.node.hash}</div>
|
onFocus={focusWindow}
|
||||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
addressBar={
|
||||||
{win.data.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||||
: win.data.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
{/* Nav buttons */}
|
||||||
: win.data.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
<button onClick={() => navBack(win.id, d)} disabled={!canBack || d.pageLoading}
|
||||||
</span>
|
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navForward(win.id, d)} disabled={!canFwd || d.pageLoading}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navReload(win.id, d)} disabled={d.pageLoading}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">
|
||||||
|
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}…/</span>{d.currentPath}
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||||
|
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||||
|
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||||
|
: d.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
||||||
|
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{d.node.type ?? "peer"}</span>
|
||||||
|
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div className="p-3 h-full overflow-auto" onClick={(e) => handleContentClick(e, win.id, d)}>
|
||||||
|
{d.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
||||||
|
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
|
||||||
|
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
|
||||||
</div>
|
</div>
|
||||||
}
|
</FloatingWindow>
|
||||||
footer={
|
);
|
||||||
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
})}
|
||||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{win.data.node.type ?? "peer"}</span>
|
|
||||||
{win.data.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.data.node.interface}</span>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="p-3 h-full overflow-auto">
|
|
||||||
{win.data.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
|
||||||
{win.data.pageError && <span className="text-destructive text-xs">{win.data.pageError}</span>}
|
|
||||||
{win.data.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.data.pageHtml }} />}
|
|
||||||
</div>
|
|
||||||
</FloatingWindow>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { usePagesStore } from "@/stores/pagesStore";
|
||||||
import { restartNode } from "@/api/client";
|
import * as api from "@/api/client";
|
||||||
import StatusBadge from "@/components/dashboard/StatusBadge";
|
import StatusBadge from "@/components/dashboard/StatusBadge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -30,39 +30,104 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useWindowManager } from "@/hooks/useWindowManager";
|
import { useWindowManager } from "@/hooks/useWindowManager";
|
||||||
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
|
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() {
|
export default function ComposeView() {
|
||||||
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
|
const { deletePage, publishPage, unpublishPage } = usePagesStore();
|
||||||
usePagesStore();
|
|
||||||
|
// File browser state
|
||||||
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
|
const [files, setFiles] = useState<api.FileEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const { windows, focusedId, open, update, close, focus } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
|
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<EditorWinData>({ w: 720, h: 520 });
|
||||||
|
|
||||||
|
// Env editor windows
|
||||||
|
const { windows: envWindows, focusedId: envFocused, open: openEnvWin, update: updateEnvWin, close: closeEnvWin, focus: focusEnvWin } = useWindowManager<EnvWinData>({ 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 openEditor = (name: string, isNew: boolean) => {
|
||||||
const id = isNew ? `new-${Date.now()}` : name;
|
// For files in subfolders, use full relative path as page name
|
||||||
open(id, { pageName: isNew ? "" : name, isNew });
|
const pageName = isNew ? "" : name;
|
||||||
|
const id = isNew ? `new-${Date.now()}` : pageName;
|
||||||
|
openEditorWin(id, { pageName, isNew });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const openEnvEditor = () => {
|
||||||
fetchPages();
|
openEnvWin("env-editor", { kind: "env" });
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRestart = async () => {
|
|
||||||
setRestarting(true);
|
|
||||||
try {
|
|
||||||
await restartNode();
|
|
||||||
toast.success("NomadNet restarted");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(`Restart failed: ${e}`);
|
|
||||||
} finally {
|
|
||||||
setRestarting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handlePublish = async (name: string) => {
|
const handlePublish = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
await publishPage(name);
|
await publishPage(name);
|
||||||
toast.success(`"${name}" published`);
|
toast.success(`"${name}" published`);
|
||||||
|
loadFiles();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`Failed: ${e}`);
|
toast.error(`Failed: ${e}`);
|
||||||
}
|
}
|
||||||
@@ -72,6 +137,7 @@ export default function ComposeView() {
|
|||||||
try {
|
try {
|
||||||
await unpublishPage(name);
|
await unpublishPage(name);
|
||||||
toast.success(`"${name}" unpublished`);
|
toast.success(`"${name}" unpublished`);
|
||||||
|
loadFiles();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`Failed: ${e}`);
|
toast.error(`Failed: ${e}`);
|
||||||
}
|
}
|
||||||
@@ -79,28 +145,63 @@ export default function ComposeView() {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!pageToDelete) return;
|
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`);
|
toast.success(`"${pageToDelete}" deleted`);
|
||||||
setPageToDelete(null);
|
setPageToDelete(null);
|
||||||
|
loadFiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading)
|
const handleCreateFolder = async () => {
|
||||||
return (
|
const name = newFolderName.trim();
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
if (!name) return;
|
||||||
Loading...
|
const folderPath = currentPath ? `${currentPath}/${name}` : name;
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex flex-col h-full">
|
||||||
<div>
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div className="flex items-center px-2 py-1.5 border-b-2 border-border">
|
<div className="flex items-center px-2 py-1.5 border-b-2 border-border">
|
||||||
<h1 className="text-xs font-semibold flex-1">Compose</h1>
|
<h1 className="text-xs font-semibold flex-1">Compose</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
<Button variant="outline" size="sm" onClick={() => setShowNewFolder(true)}>
|
||||||
<RotateCcw className="w-3 h-3 mr-1.5" />
|
<FolderPlus className="w-3 h-3 mr-1.5" />
|
||||||
Restart
|
New Folder
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => openEditor("", true)}>
|
<Button size="sm" onClick={() => openEditor("", true)}>
|
||||||
<Plus className="w-3 h-3 mr-1.5" />
|
<Plus className="w-3 h-3 mr-1.5" />
|
||||||
@@ -109,64 +210,135 @@ export default function ComposeView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Path bar */}
|
||||||
<Table>
|
<div className="flex items-center px-3 py-1.5 border-b border-border bg-muted/15 text-xs">
|
||||||
<TableHeader>
|
{currentPath && (
|
||||||
<TableRow>
|
<button onClick={navigateUp} className="mr-2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||||
<TableHead>Name</TableHead>
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
<TableHead>Title</TableHead>
|
</button>
|
||||||
<TableHead>Status</TableHead>
|
)}
|
||||||
<TableHead>Size</TableHead>
|
<button onClick={() => navigateTo("")} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
|
||||||
<TableHead className="w-8" />
|
/
|
||||||
</TableRow>
|
</button>
|
||||||
</TableHeader>
|
{pathParts.map((part, i) => {
|
||||||
<TableBody>
|
const partPath = pathParts.slice(0, i + 1).join("/");
|
||||||
{pages.map((p) => (
|
return (
|
||||||
<TableRow
|
<span key={partPath} className="flex items-center">
|
||||||
key={p.name}
|
<ChevronRight className="w-3 h-3 mx-0.5 text-muted-foreground/50" />
|
||||||
className="cursor-pointer"
|
<button onClick={() => navigateTo(partPath)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
|
||||||
onClick={() => openEditor(p.name, false)}
|
{part}
|
||||||
>
|
</button>
|
||||||
<TableCell className="font-mono">
|
</span>
|
||||||
{p.name}
|
);
|
||||||
{p.name === "index" && (
|
})}
|
||||||
<span className="ml-2 text-xs text-primary">homepage</span>
|
|
||||||
)}
|
{/* New folder inline input */}
|
||||||
</TableCell>
|
{showNewFolder && (
|
||||||
<TableCell className="text-muted-foreground">
|
<span className="flex items-center ml-4 gap-1">
|
||||||
{p.title ?? "\u2014"}
|
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||||
</TableCell>
|
<input
|
||||||
<TableCell>
|
autoFocus
|
||||||
<StatusBadge published={p.published} hasSource={p.has_source} />
|
value={newFolderName}
|
||||||
</TableCell>
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
<TableCell className="text-muted-foreground">
|
onKeyDown={(e) => {
|
||||||
{p.size != null ? `${p.size} B` : "\u2014"}
|
if (e.key === "Enter") handleCreateFolder();
|
||||||
</TableCell>
|
if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); }
|
||||||
<TableCell className="text-right w-8">
|
}}
|
||||||
<PageActions
|
placeholder="folder name"
|
||||||
published={p.published}
|
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"
|
||||||
onEdit={() => openEditor(p.name, false)}
|
/>
|
||||||
onPublish={() => handlePublish(p.name)}
|
<button onClick={handleCreateFolder} className="text-primary text-xs cursor-pointer">create</button>
|
||||||
onUnpublish={() => handleUnpublish(p.name)}
|
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="text-muted-foreground text-xs cursor-pointer">cancel</button>
|
||||||
onDelete={() => setPageToDelete(p.name)}
|
</span>
|
||||||
/>
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
|
||||||
))}
|
{/* File table */}
|
||||||
{pages.length === 0 && (
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<SortableHead label="Name" sortKey="name" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
|
||||||
colSpan={5}
|
<TableHead>Status</TableHead>
|
||||||
className="text-center text-muted-foreground py-8"
|
<SortableHead label="Size" sortKey="size" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
|
||||||
>
|
<SortableHead label="Modified" sortKey="modified" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
|
||||||
No pages yet. Create one to get started.
|
<TableHead className="w-8" />
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{sortedFiles.map((entry) => (
|
||||||
|
<TableRow
|
||||||
|
key={entry.path}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => handleFileClick(entry)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{entry.type === "folder" ? (
|
||||||
|
<Folder className="w-3.5 h-3.5 text-primary/70 shrink-0" />
|
||||||
|
) : entry.type === "env" ? (
|
||||||
|
<KeyRound className="w-3.5 h-3.5 text-amber-500/70 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<FileText className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
{entry.name === "index.uf" && (
|
||||||
|
<span className="text-[10px] text-primary">homepage</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{entry.type === "file" && entry.name.endsWith(".uf") ? (
|
||||||
|
<StatusBadge published={entry.published} hasSource={true} />
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{entry.type !== "folder" ? formatSize(entry.size) : "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{formatTime(entry.last_modified)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right w-8">
|
||||||
|
{entry.type === "file" && entry.name.endsWith(".uf") && (
|
||||||
|
<FileActions
|
||||||
|
entry={entry}
|
||||||
|
folders={files.filter(f => 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}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||||
|
{currentPath ? "Empty folder." : "No files yet. Create a page to get started."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={pageToDelete !== null}
|
open={pageToDelete !== null}
|
||||||
onOpenChange={(open) => !open && setPageToDelete(null)}
|
onOpenChange={(open) => !open && setPageToDelete(null)}
|
||||||
@@ -175,8 +347,7 @@ export default function ComposeView() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
|
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This permanently deletes the page and its source. This cannot be
|
This permanently deletes the file and its published version. This cannot be undone.
|
||||||
undone.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -191,15 +362,27 @@ export default function ComposeView() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Floating editor windows */}
|
{/* Editor windows */}
|
||||||
{windows.map((win) => (
|
{editorWindows.map((win) => (
|
||||||
<EditorWindow
|
<EditorWindow
|
||||||
key={win.id}
|
key={win.id}
|
||||||
win={win}
|
win={win}
|
||||||
focused={focusedId === win.id}
|
focused={editorFocused === win.id}
|
||||||
onUpdate={update}
|
onUpdate={updateEditorWin}
|
||||||
onClose={close}
|
onClose={closeEditorWin}
|
||||||
onFocus={focus}
|
onFocus={focusEditorWin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Env editor windows */}
|
||||||
|
{envWindows.map((win) => (
|
||||||
|
<EnvEditorWindow
|
||||||
|
key={win.id}
|
||||||
|
win={win}
|
||||||
|
focused={envFocused === win.id}
|
||||||
|
onUpdate={updateEnvWin}
|
||||||
|
onClose={closeEnvWin}
|
||||||
|
onFocus={focusEnvWin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,20 +390,40 @@ export default function ComposeView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Per-row action menu for a page. */
|
// ---------------------------------------------------------------------------
|
||||||
function PageActions({
|
// File action menu
|
||||||
published,
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FileActions({
|
||||||
|
entry,
|
||||||
|
folders,
|
||||||
|
currentPath,
|
||||||
onEdit,
|
onEdit,
|
||||||
onPublish,
|
onPublish,
|
||||||
onUnpublish,
|
onUnpublish,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMove,
|
||||||
}: {
|
}: {
|
||||||
published: boolean;
|
entry: api.FileEntry;
|
||||||
|
folders: api.FileEntry[];
|
||||||
|
currentPath: string;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
onUnpublish: () => void;
|
onUnpublish: () => void;
|
||||||
onDelete: () => 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 (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
@@ -233,12 +436,12 @@ function PageActions({
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
|
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-44 p-1">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
>Edit</button>
|
>Edit</button>
|
||||||
{published ? (
|
{entry.published ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onUnpublish(); }}
|
onClick={(e) => { e.stopPropagation(); onUnpublish(); }}
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
@@ -249,6 +452,31 @@ function PageActions({
|
|||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
>Publish</button>
|
>Publish</button>
|
||||||
)}
|
)}
|
||||||
|
{moveTargets.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowMove(!showMove); }}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer flex items-center justify-between"
|
||||||
|
>
|
||||||
|
Move to…
|
||||||
|
<ChevronRight className={`w-3 h-3 transition-transform ${showMove ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{showMove && (
|
||||||
|
<div className="border-t border-border mt-0.5 pt-0.5">
|
||||||
|
{moveTargets.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.path}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMove(t.path); }}
|
||||||
|
className="w-full text-left px-4 py-1.5 text-xs font-mono text-muted-foreground hover:text-foreground hover:bg-accent transition-colors cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Folder className="w-3 h-3 shrink-0" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||||
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
|
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
|
||||||
@@ -257,3 +485,134 @@ function PageActions({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sortable table header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SortableHead({ label, sortKey, currentKey, asc, onToggle }: {
|
||||||
|
label: string;
|
||||||
|
sortKey: "name" | "size" | "modified";
|
||||||
|
currentKey: string;
|
||||||
|
asc: boolean;
|
||||||
|
onToggle: (key: "name" | "size" | "modified") => void;
|
||||||
|
}) {
|
||||||
|
const active = currentKey === sortKey;
|
||||||
|
return (
|
||||||
|
<TableHead>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(sortKey); }}
|
||||||
|
className="flex items-center gap-1 text-inherit hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active && (asc
|
||||||
|
? <ArrowUp className="w-3 h-3" />
|
||||||
|
: <ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Env editor floating window
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EnvEditorWindow({
|
||||||
|
win,
|
||||||
|
focused,
|
||||||
|
onUpdate,
|
||||||
|
onClose,
|
||||||
|
onFocus,
|
||||||
|
}: {
|
||||||
|
win: ManagedWindow<EnvWinData>;
|
||||||
|
focused: boolean;
|
||||||
|
onUpdate: (id: string, patch: Partial<ManagedWindow<EnvWinData>>) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
onFocus: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const windowRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<FloatingWindow
|
||||||
|
id={win.id}
|
||||||
|
title=".env"
|
||||||
|
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||||
|
zIndex={win.zIndex}
|
||||||
|
focused={focused}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onClose={handleClose}
|
||||||
|
onFocus={onFocus}
|
||||||
|
minW={360} minH={200}
|
||||||
|
containerRef={windowRef}
|
||||||
|
>
|
||||||
|
<EditorPointer containerRef={windowRef} focused={focused} />
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center px-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
|
||||||
|
<KeyRound className="w-3.5 h-3.5 text-amber-500/70" />
|
||||||
|
<span className="text-xs font-semibold flex-1">Environment Variables</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !isDirty}
|
||||||
|
className="text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Help */}
|
||||||
|
<div className="px-3 py-1.5 border-b border-border bg-muted/10 text-[10px] text-muted-foreground">
|
||||||
|
One variable per line: <span className="font-mono">KEY=value</span>. Use <span className="font-mono">source name : env "KEY"</span> in pages.
|
||||||
|
</div>
|
||||||
|
{/* CodeMirror editor */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<EditorPane value={content} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FloatingWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user