feat: add settings
This commit is contained in:
@@ -222,6 +222,31 @@ export async function saveEnv(content: string): Promise<void> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
80
frontend/src/components/editor/iniHighlight.ts
Normal file
80
frontend/src/components/editor/iniHighlight.ts
Normal 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)];
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export default function BrowseView() {
|
||||
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
|
||||
const hashToIndexRef = useRef<Map<string, number>>(new Map());
|
||||
const clusterNamesRef = useRef<string[]>([]);
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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<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 (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Settings
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user