feat: add settings

This commit is contained in:
2026-04-06 01:09:02 +02:00
parent 20e41f7680
commit 6e8a4af40d
11 changed files with 425 additions and 45 deletions

View File

@@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./ COPY backend/ ./
COPY frontend/dist/ ./static/ COPY frontend/dist/ ./static/
EXPOSE 8080 EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@@ -177,6 +177,37 @@ def start_browser() -> None:
log.warning("Failed to start RNS browser: %s", exc) 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 # 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) return await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return None 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}

View File

@@ -9,15 +9,18 @@ services:
- PAGES_DIR=/data/pages - PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources - SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet - NOMADNET_CONTAINER=nomadnet
- RNS_CONFIG_DIR=/rns
- NOMADNET_CONFIG_DIR=/nomadnet
- LOG_LEVEL=DEBUG - LOG_LEVEL=DEBUG
volumes: volumes:
- pages:/data/pages - pages:/data/pages
- sources:/data/sources - sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro - /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 restart: unless-stopped
depends_on:
- nomadnet
nomadnet: nomadnet:
image: ghcr.io/markqvist/nomadnet:latest image: ghcr.io/markqvist/nomadnet:latest
@@ -26,11 +29,13 @@ services:
volumes: volumes:
- pages:/root/.nomadnetwork/storage/pages - pages:/root/.nomadnetwork/storage/pages
- nomadnet-config:/root/.nomadnetwork - nomadnet-config:/root/.nomadnetwork
- ./nomadnet.conf:/root/.nomadnetwork/config:ro - ./nomadnet.conf:/root/.nomadnetwork/config
- ./reticulum.conf:/root/.reticulum/config:ro - reticulum:/root/.reticulum
- ./reticulum.conf:/root/.reticulum/config
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pages: pages:
sources: sources:
nomadnet-config: nomadnet-config:
reticulum:

View File

@@ -222,6 +222,31 @@ export async function saveEnv(content: string): Promise<void> {
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
} }
// ---------------------------------------------------------------------------
// Config (Reticulum + NomadNet)
// ---------------------------------------------------------------------------
export async function fetchConfig(kind: "reticulum" | "nomadnet"): Promise<string> {
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<void> {
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 // Images
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -66,8 +66,10 @@ export function buildGraphArrays(
positions[i * 2] = prevPositions[prevIdx * 2]!; positions[i * 2] = prevPositions[prevIdx * 2]!;
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!; positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
} else { } else {
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; // Tighter initial spread for small graphs so they don't scatter
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; 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 // Self node = bright white, others by status
@@ -79,7 +81,8 @@ export function buildGraphArrays(
colors[i * 4 + 2] = rgba[2]; colors[i * 4 + 2] = rgba[2];
colors[i * 4 + 3] = rgba[3]; 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 // Cluster assignment
if (i === selfIndex) { if (i === selfIndex) {

View File

@@ -3,7 +3,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewP
import { EditorState, RangeSetBuilder } from "@codemirror/state"; import { EditorState, RangeSetBuilder } from "@codemirror/state";
import type { Extension } from "@codemirror/state"; import type { Extension } from "@codemirror/state";
import type { DecorationSet } from "@codemirror/view"; 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 { searchKeymap } from "@codemirror/search";
import { oneDark } from "./oneDarkTheme"; import { oneDark } from "./oneDarkTheme";
@@ -72,7 +72,7 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
lineNumbers({ formatNumber: toRoman }), lineNumbers({ formatNumber: toRoman }),
highlightActiveLine(), highlightActiveLine(),
subtleWhitespace, subtleWhitespace,
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
oneDark, oneDark,
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {

View File

@@ -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)];
}

View File

@@ -43,6 +43,8 @@ export default function BrowseView() {
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map()); const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
const hashToIndexRef = useRef<Map<string, number>>(new Map()); const hashToIndexRef = useRef<Map<string, number>>(new Map());
const clusterNamesRef = useRef<string[]>([]); const clusterNamesRef = useRef<string[]>([]);
const hasUserInteractedRef = useRef(false);
const initialFitDoneRef = useRef(false);
// Build graph data, preserving existing positions // Build graph data, preserving existing positions
const graphData = useMemo(() => { const graphData = useMemo(() => {
@@ -241,8 +243,15 @@ export default function BrowseView() {
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] }); setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
}, },
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); }, onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); }, onSimulationEnd: () => {
onZoom: () => { updateClusterLabels(); updateSearchLabels(); }, // 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; graphRef.current = graph;
@@ -263,10 +272,13 @@ export default function BrowseView() {
const n = graphData.entries.length; const n = graphData.entries.length;
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); 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({ graph.setConfig({
simulationRepulsion: 0.5 + scale * 9.5, simulationRepulsion: isSmall ? 0.3 : 0.5 + scale * 9.5,
simulationCluster: 1.0 - scale * 0.75, 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"), hoveredPointRingColor: cssVarToHex("--primary"),
}); });
@@ -435,7 +447,9 @@ export default function BrowseView() {
const clearSearch = useCallback(() => { const clearSearch = useCallback(() => {
setFilter(""); setFilter("");
graphRef.current?.unselectPoints(); graphRef.current?.unselectPoints();
// Re-center when explicitly clearing search
graphRef.current?.fitView(300, 0.2); graphRef.current?.fitView(300, 0.2);
hasUserInteractedRef.current = false;
updateClusterLabels(); updateClusterLabels();
}, [updateClusterLabels]); }, [updateClusterLabels]);

View File

@@ -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() { export default function SettingsView() {
const {
windows, focusedId,
open, update, close, focus,
} = useWindowManager<ConfigWinData>({ 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 ( return (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full gap-4">
Settings <div className="flex flex-col gap-3 text-center">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Configuration</h2>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => open("reticulum", { kind: "reticulum" })}
>
Reticulum
</Button>
<Button
variant="outline"
onClick={() => open("nomadnet", { kind: "nomadnet" })}
>
NomadNet
</Button>
</div>
<Button
variant="default"
disabled={restarting}
onClick={handleRestart}
>
{restarting ? "Restarting..." : "Apply & Restart"}
</Button>
</div>
{windows.map((win) => (
<ConfigEditorWindow
key={win.id}
win={win}
focused={focusedId === win.id}
onUpdate={update}
onClose={close}
onFocus={focus}
/>
))}
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Floating config editor window
// ---------------------------------------------------------------------------
const TITLES: Record<ConfigKind, string> = {
reticulum: "Reticulum Config",
nomadnet: "NomadNet Config",
};
function ConfigEditorWindow({
win, focused, onUpdate, onClose, onFocus,
}: {
win: ManagedWindow<ConfigWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<ConfigWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}) {
const kind = win.data.kind;
const windowRef = useRef<HTMLDivElement>(null);
const [content, setContent] = useState<string | null>(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 (
<FloatingWindow
id={win.id}
title={TITLES[kind]}
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={250}
containerRef={windowRef}
>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
<div className="flex items-center px-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
<span className="text-xs font-semibold flex-1">
{TITLES[kind]}
{isDirty && <span className="text-muted-foreground ml-1.5">(unsaved)</span>}
</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>
<div className="flex-1 min-h-0">
{content !== null ? (
<EditorPane value={content} onChange={handleChange} extensions={iniExtensions} />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
Loading...
</div>
)}
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -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

View File

@@ -22,3 +22,16 @@
mode = full mode = full
name = Quad4 name = Quad4
selected_interface_mode = 1 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