From 6e8a4af40d8349e95d97f948ebc825c54999023c Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 6 Apr 2026 01:09:02 +0200 Subject: [PATCH] feat: add settings --- Dockerfile | 1 - backend/browse.py | 80 ++++++++ compose.yml | 15 +- frontend/src/api/client.ts | 25 +++ frontend/src/components/browse/buildGraph.ts | 9 +- frontend/src/components/editor/EditorPane.tsx | 4 +- .../src/components/editor/iniHighlight.ts | 80 ++++++++ frontend/src/routes/BrowseView.tsx | 22 ++- frontend/src/routes/SettingsView.tsx | 186 +++++++++++++++++- reticulum-client.conf | 21 -- reticulum.conf | 27 ++- 11 files changed, 425 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/editor/iniHighlight.ts delete mode 100644 reticulum-client.conf diff --git a/Dockerfile b/Dockerfile index cf2102a..dc483b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY backend/ ./ COPY frontend/dist/ ./static/ - EXPOSE 8080 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/backend/browse.py b/backend/browse.py index 6e78ac0..16f85db 100644 --- a/backend/browse.py +++ b/backend/browse.py @@ -177,6 +177,37 @@ def start_browser() -> None: log.warning("Failed to start RNS browser: %s", exc) +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + +def _rns_config_path() -> Path: + """Return the path to the RNS config file.""" + configdir = os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum")) + return Path(configdir) / "config" + + +def _nomadnet_config_path() -> Path: + """Return the path to the NomadNet config file.""" + configdir = os.environ.get("NOMADNET_CONFIG_DIR", str(Path.home() / ".nomadnetwork")) + return Path(configdir) / "config" + + +def _restart_nomadnet() -> bool: + """Restart the NomadNet container. Returns True on success.""" + try: + from docker_utils import NOMADNET_CONTAINER + import docker + client = docker.from_env() + container = client.containers.get(NOMADNET_CONTAINER) + container.restart() + log.info("NomadNet container restarted") + return True + except Exception as exc: + log.info("NomadNet container not available: %s", exc) + return False + + # --------------------------------------------------------------------------- # API endpoints # --------------------------------------------------------------------------- @@ -368,3 +399,52 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None: return await asyncio.wait_for(future, timeout=30.0) except asyncio.TimeoutError: return None + + +# --------------------------------------------------------------------------- +# Reticulum config endpoints +# --------------------------------------------------------------------------- + +@router.get("/browse/config/reticulum") +async def get_reticulum_config(): + """Return the current Reticulum config file contents.""" + path = _rns_config_path() + if not path.exists(): + return {"content": ""} + return {"content": path.read_text(encoding="utf-8")} + + +@router.post("/browse/config/reticulum") +async def save_reticulum_config(body: dict): + """Write Reticulum config.""" + content = body.get("content", "") + path = _rns_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return {"ok": True} + + +@router.get("/browse/config/nomadnet") +async def get_nomadnet_config(): + """Return the current NomadNet config file contents.""" + path = _nomadnet_config_path() + if not path.exists(): + return {"content": ""} + return {"content": path.read_text(encoding="utf-8")} + + +@router.post("/browse/config/nomadnet") +async def save_nomadnet_config(body: dict): + """Write NomadNet config.""" + content = body.get("content", "") + path = _nomadnet_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return {"ok": True} + + +@router.post("/browse/config/restart") +async def restart_services(): + """Restart NomadNet to apply config changes.""" + restarted = _restart_nomadnet() + return {"ok": True, "nomadnet_restarted": restarted} diff --git a/compose.yml b/compose.yml index 13f3b41..8a421a0 100644 --- a/compose.yml +++ b/compose.yml @@ -9,15 +9,18 @@ services: - PAGES_DIR=/data/pages - SOURCES_DIR=/data/sources - NOMADNET_CONTAINER=nomadnet + - RNS_CONFIG_DIR=/rns + - NOMADNET_CONFIG_DIR=/nomadnet - LOG_LEVEL=DEBUG volumes: - pages:/data/pages - sources:/data/sources - /var/run/docker.sock:/var/run/docker.sock:ro - - ./reticulum-client.conf:/root/.reticulum/config:ro + - reticulum:/rns + - ./reticulum.conf:/rns/config + - nomadnet-config:/nomadnet + - ./nomadnet.conf:/nomadnet/config restart: unless-stopped - depends_on: - - nomadnet nomadnet: image: ghcr.io/markqvist/nomadnet:latest @@ -26,11 +29,13 @@ services: volumes: - pages:/root/.nomadnetwork/storage/pages - nomadnet-config:/root/.nomadnetwork - - ./nomadnet.conf:/root/.nomadnetwork/config:ro - - ./reticulum.conf:/root/.reticulum/config:ro + - ./nomadnet.conf:/root/.nomadnetwork/config + - reticulum:/root/.reticulum + - ./reticulum.conf:/root/.reticulum/config restart: unless-stopped volumes: pages: sources: nomadnet-config: + reticulum: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 980dd85..a47556a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -222,6 +222,31 @@ export async function saveEnv(content: string): Promise { if (!res.ok) throw new Error(await res.text()); } +// --------------------------------------------------------------------------- +// Config (Reticulum + NomadNet) +// --------------------------------------------------------------------------- + +export async function fetchConfig(kind: "reticulum" | "nomadnet"): Promise { + const res = await fetch(`/api/browse/config/${kind}`); + const data = await json<{ content: string }>(res); + return data.content; +} + +export async function saveConfig(kind: "reticulum" | "nomadnet", content: string): Promise { + const res = await fetch(`/api/browse/config/${kind}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + if (!res.ok) throw new Error(await res.text()); +} + +export async function restartServices(): Promise<{ nomadnet_restarted: boolean }> { + const res = await fetch("/api/browse/config/restart", { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + // --------------------------------------------------------------------------- // Images // --------------------------------------------------------------------------- diff --git a/frontend/src/components/browse/buildGraph.ts b/frontend/src/components/browse/buildGraph.ts index 038090d..28bdc6c 100644 --- a/frontend/src/components/browse/buildGraph.ts +++ b/frontend/src/components/browse/buildGraph.ts @@ -66,8 +66,10 @@ export function buildGraphArrays( positions[i * 2] = prevPositions[prevIdx * 2]!; positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!; } else { - positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; - positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; + // Tighter initial spread for small graphs so they don't scatter + const spread = n <= 20 ? 0.1 : n <= 100 ? 0.25 : 0.5; + positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * spread; + positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * spread; } // Self node = bright white, others by status @@ -79,7 +81,8 @@ export function buildGraphArrays( colors[i * 4 + 2] = rgba[2]; colors[i * 4 + 3] = rgba[3]; - sizes[i] = 3; + // Scale node size inversely with count: bigger dots when fewer nodes + sizes[i] = n <= 10 ? 8 : n <= 50 ? 5 : n <= 200 ? 4 : 3; // Cluster assignment if (i === selfIndex) { diff --git a/frontend/src/components/editor/EditorPane.tsx b/frontend/src/components/editor/EditorPane.tsx index dc3e65a..c69bf6a 100644 --- a/frontend/src/components/editor/EditorPane.tsx +++ b/frontend/src/components/editor/EditorPane.tsx @@ -3,7 +3,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewP import { EditorState, RangeSetBuilder } from "@codemirror/state"; import type { Extension } from "@codemirror/state"; import type { DecorationSet } from "@codemirror/view"; -import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { searchKeymap } from "@codemirror/search"; import { oneDark } from "./oneDarkTheme"; @@ -72,7 +72,7 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props) lineNumbers({ formatNumber: toRoman }), highlightActiveLine(), subtleWhitespace, - keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]), oneDark, EditorView.updateListener.of((update) => { if (update.docChanged) { diff --git a/frontend/src/components/editor/iniHighlight.ts b/frontend/src/components/editor/iniHighlight.ts new file mode 100644 index 0000000..eeafbec --- /dev/null +++ b/frontend/src/components/editor/iniHighlight.ts @@ -0,0 +1,80 @@ +import { + StreamLanguage, + HighlightStyle, + syntaxHighlighting, +} from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +/** + * CodeMirror 6 syntax highlighting for INI-style config files + * (Reticulum .conf / NomadNet .conf). + * + * Supports: [sections], [[subsections]], key = value, # comments, + * booleans, numbers, and quoted strings. + */ + +const BOOLEANS = new Set([ + "true", "false", "yes", "no", "on", "off", "none", +]); + +const iniLanguage = StreamLanguage.define({ + token(stream) { + // Comments + if (stream.match(/\s*#/)) { + stream.skipToEnd(); + return "lineComment"; + } + + // Skip whitespace + if (stream.eatSpace()) return null; + + // Subsection headers [[name]] + if (stream.match(/\[\[.*?\]\]/)) return "heading"; + + // Section headers [name] + if (stream.match(/\[.*?\]/)) return "typeName"; + + // Quoted strings + if (stream.match(/"/)) { + while (!stream.eol()) { + if (stream.next() === '"') break; + } + return "string"; + } + + // Assignment operator + if (stream.match(/=/)) return "punctuation"; + + // Numbers (integers, floats, ports, IPs with dots) + if (stream.match(/\b\d[\d.]*\b/)) return "number"; + + // Words + if (stream.match(/[\w\-_.]+/)) { + const word = stream.current().toLowerCase(); + if (BOOLEANS.has(word)) return "atom"; + // Keys appear before '=', values after — both are plain words + return null; + } + + stream.next(); + return null; + }, + startState: () => ({}), + copyState: (s) => ({ ...s }), + blankLine: () => {}, + languageData: {}, +}); + +const iniStyle = HighlightStyle.define([ + { tag: tags.typeName, color: "#c792ea", fontWeight: "bold" }, // [section] + { tag: tags.heading, color: "#82aaff", fontWeight: "bold" }, // [[subsection]] + { tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" }, + { tag: tags.string, color: "#c3e88d" }, + { tag: tags.number, color: "#f78c6c" }, + { tag: tags.atom, color: "#89ddff" }, // booleans + { tag: tags.punctuation, color: "#89ddff" }, // = +]); + +export function iniHighlight() { + return [iniLanguage, syntaxHighlighting(iniStyle)]; +} diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index 4feb841..34c547c 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -43,6 +43,8 @@ export default function BrowseView() { const nodesMapRef = useRef>(new Map()); const hashToIndexRef = useRef>(new Map()); const clusterNamesRef = useRef([]); + const hasUserInteractedRef = useRef(false); + const initialFitDoneRef = useRef(false); // Build graph data, preserving existing positions const graphData = useMemo(() => { @@ -241,8 +243,15 @@ export default function BrowseView() { setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] }); }, onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); }, - onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); }, - onZoom: () => { updateClusterLabels(); updateSearchLabels(); }, + onSimulationEnd: () => { + // Only auto-fit on initial load; after that, respect user's viewport + if (!initialFitDoneRef.current) { + graphRef.current?.fitView(300, 0.2); + initialFitDoneRef.current = true; + } + updateClusterLabels(); updateSearchLabels(); + }, + onZoom: () => { hasUserInteractedRef.current = true; updateClusterLabels(); updateSearchLabels(); }, }); graphRef.current = graph; @@ -263,10 +272,13 @@ export default function BrowseView() { const n = graphData.entries.length; const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); + // Stronger gravity + lower repulsion for small graphs keeps them compact and centered + const isSmall = n <= 20; graph.setConfig({ - simulationRepulsion: 0.5 + scale * 9.5, + simulationRepulsion: isSmall ? 0.3 : 0.5 + scale * 9.5, simulationCluster: 1.0 - scale * 0.75, - simulationGravity: 0.25 + scale * 1.75, + simulationGravity: isSmall ? 1.0 : 0.25 + scale * 1.75, + simulationDecay: isSmall ? 3000 : 5000, hoveredPointRingColor: cssVarToHex("--primary"), }); @@ -435,7 +447,9 @@ export default function BrowseView() { const clearSearch = useCallback(() => { setFilter(""); graphRef.current?.unselectPoints(); + // Re-center when explicitly clearing search graphRef.current?.fitView(300, 0.2); + hasUserInteractedRef.current = false; updateClusterLabels(); }, [updateClusterLabels]); diff --git a/frontend/src/routes/SettingsView.tsx b/frontend/src/routes/SettingsView.tsx index 05b52bb..c9ddcc9 100644 --- a/frontend/src/routes/SettingsView.tsx +++ b/frontend/src/routes/SettingsView.tsx @@ -1,7 +1,189 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import * as api from "@/api/client"; +import { useWindowManager } from "@/hooks/useWindowManager"; +import { useKeyboardSave } from "@/hooks/useKeyboardSave"; +import FloatingWindow from "@/components/shared/FloatingWindow"; +import EditorPane from "@/components/editor/EditorPane"; +import EditorPointer from "@/components/editor/EditorPointer"; +import { iniHighlight } from "@/components/editor/iniHighlight"; +import { Button } from "@/components/ui/button"; +import type { ManagedWindow } from "@/hooks/useWindowManager"; + +// --------------------------------------------------------------------------- +// Settings view — config editors + restart +// --------------------------------------------------------------------------- + +const iniExtensions = iniHighlight(); + +type ConfigKind = "reticulum" | "nomadnet"; + +interface ConfigWinData { + kind: ConfigKind; +} + export default function SettingsView() { + const { + windows, focusedId, + open, update, close, focus, + } = useWindowManager({ w: 560, h: 440 }); + + const [restarting, setRestarting] = useState(false); + + const handleRestart = useCallback(async () => { + setRestarting(true); + try { + const result = await api.restartServices(); + toast.success( + result.nomadnet_restarted + ? "NomadNet restarted" + : "NomadNet container not found", + ); + } catch { + toast.error("Restart failed"); + } finally { + setRestarting(false); + } + }, []); + return ( -
- Settings +
+
+

Configuration

+
+ + +
+ +
+ + {windows.map((win) => ( + + ))}
); } + +// --------------------------------------------------------------------------- +// Floating config editor window +// --------------------------------------------------------------------------- + +const TITLES: Record = { + reticulum: "Reticulum Config", + nomadnet: "NomadNet Config", +}; + +function ConfigEditorWindow({ + win, focused, onUpdate, onClose, onFocus, +}: { + win: ManagedWindow; + focused: boolean; + onUpdate: (id: string, patch: Partial>) => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; +}) { + const kind = win.data.kind; + const windowRef = useRef(null); + const [content, setContent] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [saving, setSaving] = useState(false); + const savedRef = useRef(""); + + useEffect(() => { + api.fetchConfig(kind).then((c) => { + setContent(c); + savedRef.current = c; + }).catch(() => toast.error(`Failed to load ${kind} config`)); + }, [kind]); + + const handleChange = useCallback((v: string) => { + setContent(v); + setIsDirty(v !== savedRef.current); + }, []); + + const handleSave = useCallback(async () => { + if (content === null) return; + setSaving(true); + try { + await api.saveConfig(kind, content); + savedRef.current = content; + setIsDirty(false); + toast.success(`${TITLES[kind]} saved`); + } catch { + toast.error("Save failed"); + } finally { + setSaving(false); + } + }, [content, kind]); + + useKeyboardSave(handleSave, undefined, focused); + + const handleClose = useCallback((id: string) => { + if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return; + onClose(id); + }, [isDirty, onClose]); + + return ( + + +
+
+ + {TITLES[kind]} + {isDirty && (unsaved)} + + +
+
+ {content !== null ? ( + + ) : ( +
+ Loading... +
+ )} +
+
+
+ ); +} diff --git a/reticulum-client.conf b/reticulum-client.conf deleted file mode 100644 index e0f6854..0000000 --- a/reticulum-client.conf +++ /dev/null @@ -1,21 +0,0 @@ -[reticulum] - enable_transport = False - share_instance = No - -[logging] - loglevel = 4 - -[interfaces] - # Connect to NomadNet's TCP server for local traffic - [[NomadNet Link]] - type = TCPClientInterface - enabled = Yes - target_host = nomadnet - target_port = 4242 - - # Connect to Quad4 directly for external node announces - [[Quad4]] - type = TCPClientInterface - enabled = Yes - target_host = 62.151.179.77 - target_port = 45657 diff --git a/reticulum.conf b/reticulum.conf index ffa46de..ffc325c 100644 --- a/reticulum.conf +++ b/reticulum.conf @@ -15,10 +15,23 @@ listen_port = 4242 [[Quad4]] - type = TCPClientInterface - interface_enabled = true - target_host = 62.151.179.77 - target_port = 45657 - mode = full - name = Quad4 - selected_interface_mode = 1 + type = TCPClientInterface + interface_enabled = true + target_host = 62.151.179.77 + target_port = 45657 + mode = full + name = Quad4 + selected_interface_mode = 1 + + [[Hispagatos_org_HQ]] + type = BackboneInterface + enabled = yes + remote = reticulum.hispagatos.org + target_port = 4242 + transport_identity = 305c8452b222c9d367bc9e482956a4fa + + [[NomadNet Link]] + type = TCPClientInterface + enabled = Yes + target_host = nomadnet + target_port = 4242