feat: templates

This commit is contained in:
2026-04-01 07:50:43 +02:00
parent b40c6436cd
commit df11705875
10 changed files with 589 additions and 19 deletions

View File

@@ -1,5 +1,6 @@
import os import os
import re import re
import shlex
from pathlib import Path from pathlib import Path
from fastapi import APIRouter from fastapi import APIRouter
@@ -10,8 +11,10 @@ router = APIRouter()
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
# Matches Micron links: [label`slug] or [label`slug.mu] # Match µFrame link nodes: link "display" "/page/slug.mu" or link "display" "slug"
_INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]') _UF_LINK = re.compile(r'^\s*link\s+', re.IGNORECASE)
# Fallback: Micron links [label`slug] or [label`slug.mu]
_MICRON_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
class GraphNode(BaseModel): class GraphNode(BaseModel):
@@ -38,19 +41,78 @@ def _all_page_names() -> set[str]:
names.add(f.stem) names.add(f.stem)
if SOURCES_DIR.is_dir(): if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir(): for f in SOURCES_DIR.iterdir():
if f.suffix == ".mu" and f.is_file(): if f.suffix in (".uf", ".mu") and f.is_file():
names.add(f.stem) names.add(f.stem)
return names return names
def _extract_title(micron: str) -> str | None: def _extract_title(source: str) -> str | None:
for line in micron.splitlines(): """Extract title from µFrame page or heading, or legacy Micron >Title."""
for line in source.splitlines():
stripped = line.strip() stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.lower().startswith("page "):
try:
parts = shlex.split(stripped)
if len(parts) >= 2:
return parts[1]
except ValueError:
pass
break
if stripped.lower().startswith("heading "):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
return parts[2]
except ValueError:
pass
break
if stripped.startswith(">") and not stripped.startswith(">>"): if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip() return stripped[1:].strip()
break
return None return None
def _extract_links(source: str, all_names: set[str]) -> list[str]:
"""Extract internal link targets from µFrame or Micron source."""
targets: list[str] = []
for line in source.splitlines():
stripped = line.strip()
# µFrame: link "display" "/page/slug.mu" or link "display" "slug"
if _UF_LINK.match(stripped):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
dest = parts[2]
# Normalize: /page/slug.mu → slug
slug = dest.rsplit("/", 1)[-1].removesuffix(".mu")
if slug in all_names:
targets.append(slug)
except ValueError:
pass
continue
# Fallback: Micron link syntax [label`slug]
for m in _MICRON_LINK.finditer(stripped):
slug = m.group(2)
if slug in all_names:
targets.append(slug)
return targets
def _source_path(name: str) -> Path | None:
"""Get source file path, preferring .uf over .mu."""
uf = SOURCES_DIR / f"{name}.uf"
if uf.is_file():
return uf
mu = SOURCES_DIR / f"{name}.mu"
return mu if mu.is_file() else None
@router.get("/graph", response_model=GraphData) @router.get("/graph", response_model=GraphData)
async def get_graph(): async def get_graph():
all_names = _all_page_names() all_names = _all_page_names()
@@ -58,18 +120,15 @@ async def get_graph():
edges: list[GraphEdge] = [] edges: list[GraphEdge] = []
for name in sorted(all_names): for name in sorted(all_names):
src_path = SOURCES_DIR / f"{name}.mu" src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu" mu_path = PAGES_DIR / f"{name}.mu"
title = None title = None
if src_path.is_file(): if src_path:
content = src_path.read_text(encoding="utf-8") content = src_path.read_text(encoding="utf-8")
title = _extract_title(content) title = _extract_title(content)
for target in _extract_links(content, all_names):
for match in _INTERNAL_LINK.finditer(content): edges.append(GraphEdge(source=name, target=target))
target = match.group(2)
if target in all_names:
edges.append(GraphEdge(source=name, target=target))
nodes.append(GraphNode( nodes.append(GraphNode(
id=name, id=name,

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from uframe.ir import ( from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, Rect, IRNode, Page, Box, Row, Col, Spacer, Pad, Rect,
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Gauge, Sparkline, Status, Table,
) )
@@ -112,7 +112,7 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
return node.rect.h return node.rect.h
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem, elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status)): Gauge, Sparkline, Status, Table)):
node.rect.h = node.pref_height node.rect.h = node.pref_height
return node.pref_height return node.pref_height

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from uframe.ir import ( from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Gauge, Sparkline, Status, Table,
) )
@@ -194,6 +194,14 @@ def measure(node: IRNode, available_width: int) -> None:
node.pref_height = 1 node.pref_height = 1
node.min_height = 1 node.min_height = 1
elif isinstance(node, Table):
# Height = header border + header row + separator + data rows + bottom border
num_rows = len(node.rows)
node.pref_width = available_width
node.min_width = len(node.columns) * 3 + 1 # minimum 3 chars per col + borders
node.pref_height = num_rows + 4 # top border + header + separator + rows + bottom border
node.min_height = 4
else: else:
# Generic: just measure children # Generic: just measure children
total_h = 0 total_h = 0

View File

@@ -10,15 +10,15 @@ from __future__ import annotations
import textwrap import textwrap
from uframe.chars import ( from uframe.chars import (
DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY, BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
STATUS_CHARS, STATUS_COLORS, sparkline_chars, STATUS_CHARS, STATUS_COLORS, sparkline_chars,
) )
from uframe.grid import CharGrid, CellStyle from uframe.grid import CharGrid, CellStyle
from uframe.ir import ( from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Gauge, Sparkline, Status, Table,
HeadingLevel, DividerStyle, ListStyle, Align, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
@@ -210,7 +210,133 @@ def paint(node: IRNode, grid: CharGrid) -> None:
grid.put(x, y, char, style=CellStyle(fg=color)) grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label) grid.put_text(x + 2, y, node.label)
elif isinstance(node, Table):
_paint_table(node, grid, x, y, w)
else: else:
# Generic: paint children # Generic: paint children
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid)
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
"""Paint a box-drawn table with header and data rows."""
if not node.columns:
return
ch = BOX_CHARS[BorderWeight.LIGHT]
border_style = CellStyle()
header_style = CellStyle(bold=True)
num_cols = len(node.columns)
# Calculate column widths
# If columns have explicit widths, use them. Otherwise distribute evenly.
col_widths: list[int] = []
total_explicit = 0
auto_count = 0
for _, cw in node.columns:
if cw > 0:
col_widths.append(cw)
total_explicit += cw
else:
col_widths.append(0)
auto_count += 1
# Available inner width = total - borders (num_cols + 1 border chars)
inner_w = w - (num_cols + 1)
if auto_count > 0:
auto_each = max(1, (inner_w - total_explicit) // auto_count)
for i in range(len(col_widths)):
if col_widths[i] == 0:
col_widths[i] = auto_each
# Compute column x positions (after each left border)
col_x: list[int] = []
cx = x + 1 # after left border
for cw in col_widths:
col_x.append(cx)
cx += cw + 1 # +1 for separator
table_w = cx - x # total table width including right border
row_y = y
# ── Top border ──
grid.put(x, row_y, ch["tl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["tr"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
# Column separator on top border
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_down"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header row ──
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, (col_name, _) in enumerate(node.columns):
text = col_name[:col_widths[ci]].ljust(col_widths[ci])
grid.put_text(col_x[ci], row_y, text, style=header_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header separator ──
grid.put(x, row_y, ch["t_right"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["t_left"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["cross"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Data rows ──
cell_style = CellStyle()
for row_data in node.rows:
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci in range(num_cols):
cell_text = row_data[ci] if ci < len(row_data) else ""
text = cell_text[:col_widths[ci]].ljust(col_widths[ci])
# Check for @color{hex}{text} modifiers in cell content
if "@" in cell_text:
spans = []
import re as _re
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
pos = 0
styled_parts: list[tuple[str, CellStyle]] = []
for m in pattern.finditer(cell_text):
if m.start() > pos:
styled_parts.append((cell_text[pos:m.start()], cell_style))
styled_parts.append((m.group(2), CellStyle(fg=m.group(1))))
pos = m.end()
if pos < len(cell_text):
styled_parts.append((cell_text[pos:], cell_style))
col_pos = col_x[ci]
for part_text, part_style in styled_parts:
for pch in part_text:
if col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, pch, style=part_style)
col_pos += 1
# Pad remaining
while col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, " ")
col_pos += 1
else:
grid.put_text(col_x[ci], row_y, text, style=cell_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Bottom border ──
grid.put(x, row_y, ch["bl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["br"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_up"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)

View File

@@ -161,6 +161,15 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return Box(title=title, weight=weight, source_line=line_num) return Box(title=title, weight=weight, source_line=line_num)
elif keyword == "row": elif keyword == "row":
# Table row (has | separators) vs layout Row (has gap number or nothing)
raw = " ".join(args)
if "|" in raw:
# Split by | and strip quotes from each cell, preserving @modifiers
cells: list[str] = []
for part in raw.split("|"):
cell = part.strip().strip('"')
cells.append(cell)
return _TableRow(cells, line_num)
gap = int(args[0]) if args else 1 gap = int(args[0]) if args else 1
return Row(gap=gap, source_line=line_num) return Row(gap=gap, source_line=line_num)
@@ -263,10 +272,41 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
state = args[1] if len(args) > 1 else "unknown" state = args[1] if len(args) > 1 else "unknown"
return Status(label=label, state=state, source_line=line_num) return Status(label=label, state=state, source_line=line_num)
elif keyword == "table":
title = args[0] if args else ""
return Table(title=title, source_line=line_num)
elif keyword == "columns":
# columns "Name" 24 | "Hops" 6 | "Status" 10
# Re-join args and split by |
raw = " ".join(args)
cols: list[tuple[str, int]] = []
for part in raw.split("|"):
tokens = _split_args(part.strip())
if tokens:
col_name = tokens[0]
col_w = int(tokens[1]) if len(tokens) > 1 else 0
cols.append((col_name, col_w))
return _TableColumns(cols, line_num)
else: else:
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num) raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
class _TableColumns(IRNode):
"""Temporary node — absorbed by parent Table during tree building."""
def __init__(self, columns: list[tuple[str, int]], line_num: int):
super().__init__(source_line=line_num)
self.columns = columns
class _TableRow(IRNode):
"""Temporary node — absorbed by parent Table during tree building."""
def __init__(self, cells: list[str], line_num: int):
super().__init__(source_line=line_num)
self.cells = cells
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Style directives — pseudo-nodes that modify their parent's style # Style directives — pseudo-nodes that modify their parent's style
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -326,6 +366,17 @@ def parse(source: str) -> Page:
setattr(parent.style, node.attr, node.value) setattr(parent.style, node.attr, node.value)
continue continue
# Table children: columns and rows are absorbed by the Table node
if isinstance(node, _TableColumns):
if stack and isinstance(stack[-1][1], Table):
stack[-1][1].columns = node.columns
continue
if isinstance(node, _TableRow):
if stack and isinstance(stack[-1][1], Table):
stack[-1][1].rows.append(node.cells)
continue
# Attach to parent # Attach to parent
if stack: if stack:
parent = stack[-1][1] parent = stack[-1][1]

View File

@@ -168,6 +168,37 @@ def test_status():
assert "Server" in result.ascii assert "Server" in result.ascii
def test_table():
source = '''page "Demo" 50
table "Routes"
columns "Destination" 20 | "Hops" 6 | "Status" 10
row "relay-east" | "2" | "alive"
row "bridge-south" | "4" | "alive"
row "node-gamma" | "7" | "stale"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "" in result.ascii # column separators at header line
assert "Destination" in result.ascii
assert "relay-east" in result.ascii
assert "bridge-south" in result.ascii
assert "stale" in result.ascii
# Should have proper structure
lines = result.ascii.split("\n")
assert len(lines) >= 6 # top border + header + sep + 3 rows + bottom border
def test_table_with_color():
source = '''page "Demo" 50
table "Peers"
columns "Name" 16 | "State" 12
row "east-relay" | "@color{0f0}{● alive}"
row "node-gamma" | "@color{f00}{○ stale}"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "" in result.ascii
assert "`F0f0" in result.micron # green color tag
def test_full_dashboard(): def test_full_dashboard():
"""Integration test: a realistic dashboard layout.""" """Integration test: a realistic dashboard layout."""
source = '''page "Node Status" 60 source = '''page "Node Status" 60

View File

@@ -1,14 +1,25 @@
import { useState } from "react";
import { BookOpen } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
} from "@/components/ui/popover";
import { useEditorStore } from "@/stores/editorStore"; import { useEditorStore } from "@/stores/editorStore";
import { useBacklinks } from "@/hooks/useBacklinks"; import { useBacklinks } from "@/hooks/useBacklinks";
import BacklinkIndicator from "@/components/editor/BacklinkIndicator"; import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
import { EXAMPLES } from "@/components/editor/examples";
interface Props { interface Props {
pageName: string; pageName: string;
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
onSaveDraft: () => void; onSaveDraft: () => void;
onPublish: () => void; onPublish: () => void;
onInsertExample: (source: string) => void;
saving: boolean; saving: boolean;
isDirty: boolean; isDirty: boolean;
} }
@@ -18,11 +29,13 @@ export default function ToolBar({
onNameChange, onNameChange,
onSaveDraft, onSaveDraft,
onPublish, onPublish,
onInsertExample,
saving, saving,
isDirty, isDirty,
}: Props) { }: Props) {
const currentPage = useEditorStore((s) => s.currentPage); const currentPage = useEditorStore((s) => s.currentPage);
const backlinks = useBacklinks(currentPage?.name); const backlinks = useBacklinks(currentPage?.name);
const [examplesOpen, setExamplesOpen] = useState(false);
return ( return (
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0"> <div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
@@ -37,6 +50,42 @@ export default function ToolBar({
<span className="font-mono font-semibold text-sm">{pageName}</span> <span className="font-mono font-semibold text-sm">{pageName}</span>
)} )}
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
<PopoverTrigger
render={
<button
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium h-8 px-3 border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
title="Insert example template"
>
<BookOpen className="h-3.5 w-3.5" />
<span>Examples</span>
</button>
}
/>
<PopoverContent side="bottom" align="start" sideOffset={8}>
<PopoverHeader>
<PopoverTitle>Insert Example</PopoverTitle>
</PopoverHeader>
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
{EXAMPLES.map((ex) => (
<button
key={ex.name}
onClick={() => {
onInsertExample(ex.source);
setExamplesOpen(false);
}}
className="flex flex-col items-start rounded-md px-2 py-1.5 text-left hover:bg-accent transition-colors"
>
<span className="text-sm font-medium">{ex.name}</span>
<span className="text-xs text-muted-foreground leading-tight">
{ex.description}
</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex-1" /> <div className="flex-1" />
<BacklinkIndicator backlinks={backlinks} /> <BacklinkIndicator backlinks={backlinks} />
@@ -45,7 +94,12 @@ export default function ToolBar({
<span className="text-xs text-muted-foreground">Unsaved</span> <span className="text-xs text-muted-foreground">Unsaved</span>
)} )}
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}> <Button
variant="outline"
size="sm"
onClick={onSaveDraft}
disabled={saving}
>
Save Draft Save Draft
</Button> </Button>
<Button size="sm" onClick={onPublish} disabled={saving}> <Button size="sm" onClick={onPublish} disabled={saving}>

View File

@@ -0,0 +1,230 @@
export interface Example {
name: string;
description: string;
source: string;
}
export const EXAMPLES: Example[] = [
{
name: "Hello World",
description: "Minimal page with a heading and text",
source: `page "Hello" 50
heading 1 "Welcome"
text "This is your first page."
spacer
heading 2 "About"
text "Built with the uFrame DSL."
divider light
link "Home" "/page/index.mu"`,
},
{
name: "Node Dashboard",
description: "System gauges, status indicators, and network info",
source: `page "Node Status" 60
box double "Relay Alpha-7"
align center
text "Reticulum Network Node"
text "Online 14d 3h 22m"
spacer
heading 1 "Resources"
row 2
col 28
gauge "CPU" 62 100 24 warn=75 crit=90
col 28
gauge "MEM" 84 100 24 warn=80 crit=95
spacer
heading 1 "Network"
row 2
col 30
label "Peers" "7 / 12"
label "Uptime" "14d 3h 22m"
col 28
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
status "Hub North" offline
divider heavy
text "Press Ctrl+R to refresh"`,
},
{
name: "Routing Table",
description: "Box-drawn table with colored status indicators",
source: `page "Routes" 60
heading 1 "Routing Table"
table "Active Routes"
columns "Destination" 22 | "Hops" 6 | "RTT" 8 | "State" 12
row "a7f2::relay-east" | "2" | "34ms" | "@color{0f0}{● alive}"
row "c4e1::bridge-south" | "4" | "112ms" | "@color{0f0}{● alive}"
row "01ab::node-gamma" | "7" | "580ms" | "@color{f00}{○ stale}"
row "f390::hub-north" | "1" | "8ms" | "@color{0f0}{● alive}"
spacer
label "Total routes" "4"
label "Average RTT" "183ms"
divider light
link "Refresh" "/page/routes.mu"`,
},
{
name: "Nested Boxes",
description: "Four box styles with nested content",
source: `page "Box Styles" 50
heading 1 "Box Styles"
spacer
box light "Light Border"
text "Standard border style"
text "Good for general content"
spacer
box heavy "Heavy Border"
text "Thick borders for emphasis"
text "Use for alerts or highlights"
spacer
box double "Double Border"
text "Double-line borders"
text "Great for titles and headers"
spacer
box rounded "Rounded Border"
text "Soft corners"
text "A more modern feel"
spacer
heading 2 "Nested"
box double "Outer"
text "This box contains another:"
box light "Inner"
text "Nested content here"`,
},
{
name: "Data Visualization",
description: "Gauges, sparklines, and status indicators",
source: `page "Metrics" 60
box heavy "System Metrics"
align center
text "Real-time monitoring dashboard"
spacer
heading 1 "CPU & Memory"
gauge "CPU" 42 100 28
gauge "GPU" 21 100 28
gauge "MEM" 67 100 28 warn=80 crit=95
gauge "SWP" 3 100 28
spacer
heading 1 "Network Traffic"
sparkline "Inbound" "1,3,5,8,7,5,3,2,1,3,6,8,7,4" 20
sparkline "Outbound" "2,2,3,5,8,7,5,3,2,1,1,3,5,8" 20
spacer
heading 1 "Services"
row 2
col 28
status "NomadNet" online
status "LXMF Router" online
status "Sideband" online
col 28
status "Reticulum" online
status "TCP Interface" degraded
status "I2P Transport" offline
divider heavy
text "Last updated: just now"`,
},
{
name: "Full Node Page",
description: "Complete node page with all primitives",
source: `page "Relay Alpha-7" 64
box double "Relay Alpha-7"
align center
text "Reticulum Mesh Node"
text "Sector 7 - Grid Reference 4E"
spacer
heading 1 "Resources"
row 2
col 30
gauge "CPU" 62 100 26 warn=75 crit=90
gauge "MEM" 84 100 26 warn=80 crit=95
col 30
gauge "DISK" 45 100 26 warn=85 crit=95
gauge "NET" 23 100 26
spacer
heading 1 "Network"
sparkline "Traffic IN" "1,4,6,8,7,5,3,2,3,5,7,8,6,4" 20
sparkline "Traffic OUT" "2,3,5,7,8,6,4,3,4,6,8,7,5,3" 20
spacer
heading 1 "Routing"
table "Active Routes"
columns "Destination" 20 | "Hops" 6 | "RTT" 8 | "State" 10
row "relay-east" | "2" | "34ms" | "@color{0f0}{● up}"
row "bridge-south" | "4" | "112ms" | "@color{0f0}{● up}"
row "node-gamma" | "7" | "580ms" | "@color{f00}{○ down}"
row "hub-north" | "1" | "8ms" | "@color{0f0}{● up}"
spacer
heading 1 "Peers"
row 2
col 30
status "East Relay" online
status "South Bridge" online
status "Backup Node" degraded
col 30
label "Active" "7 / 12"
label "Uptime" "14d 3h"
label "Version" "0.7.2"
divider heavy
row 2
col 30
link "Home" "/page/index.mu"
col 30
link "Settings" "/page/settings.mu"`,
},
];

View File

@@ -145,6 +145,16 @@ const COMMANDS: CmdEntry[] = [
apply: insert("bold"), apply: insert("bold"),
}, },
// Table
{
label: "table",
detail: 'table + columns + rows',
section: "Data",
apply: insert(
`table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"`,
),
},
// Templates // Templates
{ {
label: "dashboard", label: "dashboard",

View File

@@ -129,6 +129,7 @@ export default function EditorView() {
onNameChange={isNew ? setPageName : undefined} onNameChange={isNew ? setPageName : undefined}
onSaveDraft={() => handleSave(false)} onSaveDraft={() => handleSave(false)}
onPublish={() => handleSave(true)} onPublish={() => handleSave(true)}
onInsertExample={(source) => setSource(source)}
saving={saving} saving={saving}
isDirty={isDirty} isDirty={isDirty}
/> />