The console gains live views of nodes, GPUs, models, replicas, usage and agent logs, and a settings tree over every key of server.yaml (agent.yaml on a node), generated from the configuration structs: values with their defaults and doc comments, edited in dialogs, saved only once the service's own loader accepts them, comments kept. The generator now tells blocks apart by package, which brings the TLS, engine and supervisor settings back into the reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
514 lines
13 KiB
Go
514 lines
13 KiB
Go
// Package settings edits llmgw's configuration files key by key, for the
|
|
// settings screens: what each key is comes from the generated reference, so
|
|
// every setting the code reads is there without anyone listing it, and the
|
|
// file is edited as YAML nodes, so its comments and order survive.
|
|
package settings
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen"
|
|
)
|
|
|
|
// Kind is how a key is shown and edited.
|
|
type Kind int
|
|
|
|
// The kinds of key.
|
|
const (
|
|
Text Kind = iota
|
|
Int
|
|
Float
|
|
Bool
|
|
Duration
|
|
TextList // []string, []int: one value a line
|
|
TextMap // map[string]string and the like: key = value lines
|
|
Block // a nested block: a submenu
|
|
BlockList // a list of blocks: a submenu of entries
|
|
BlockMap // a map of blocks: a submenu of named entries
|
|
)
|
|
|
|
// Key is one key of a block, as the screens need it.
|
|
type Key struct {
|
|
docgen.Field
|
|
Kind Kind
|
|
// Block is the block it opens, for Block, BlockList and BlockMap.
|
|
Block *docgen.Block
|
|
}
|
|
|
|
// Blocks finds a block by ID in the generated reference.
|
|
func Find(id string) (*docgen.Block, bool) {
|
|
for i := range docgen.Reference {
|
|
if docgen.Reference[i].ID() == id {
|
|
return &docgen.Reference[i], true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// Keys are a block's keys with their kinds.
|
|
func Keys(b *docgen.Block) []Key {
|
|
out := make([]Key, 0, len(b.Fields))
|
|
for _, f := range b.Fields {
|
|
k := Key{Field: f}
|
|
k.Kind, k.Block = kindOf(b.Package, f)
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func kindOf(pkg string, f docgen.Field) (Kind, *docgen.Block) {
|
|
if nb, ok := Find(f.Nested); ok {
|
|
return Block, nb
|
|
}
|
|
t := strings.TrimPrefix(f.Type, "*")
|
|
switch t {
|
|
case "string":
|
|
return Text, nil
|
|
case "int", "int64", "uint", "uint32":
|
|
return Int, nil
|
|
case "float64", "float32":
|
|
return Float, nil
|
|
case "bool":
|
|
return Bool, nil
|
|
case "time.Duration":
|
|
return Duration, nil
|
|
}
|
|
if elem, ok := strings.CutPrefix(t, "[]"); ok {
|
|
if b, ok := Find(qualify(pkg, elem)); ok {
|
|
return BlockList, b
|
|
}
|
|
return TextList, nil
|
|
}
|
|
if strings.HasPrefix(t, "map[") {
|
|
elem := t[strings.Index(t, "]")+1:]
|
|
if b, ok := Find(qualify(pkg, elem)); ok {
|
|
return BlockMap, b
|
|
}
|
|
return TextMap, nil
|
|
}
|
|
return Text, nil
|
|
}
|
|
|
|
// qualify is a type as a block ID: Endpoint in config is config.Endpoint.
|
|
func qualify(pkg, typ string) string {
|
|
if strings.Contains(typ, ".") {
|
|
return typ
|
|
}
|
|
return pkg + "." + typ
|
|
}
|
|
|
|
// Root is the block a file is: server.yaml is config.Server, agent.yaml the
|
|
// agent's Config.
|
|
func Root(file string) (*docgen.Block, error) {
|
|
id := "config.Server"
|
|
if strings.Contains(file, "agent") {
|
|
id = "agent.Config"
|
|
}
|
|
b, ok := Find(id)
|
|
if !ok {
|
|
return nil, fmt.Errorf("no block %s in the reference", id)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Doc is a configuration file, open for editing.
|
|
type Doc struct {
|
|
Path string
|
|
root *yaml.Node // the document's top mapping
|
|
doc yaml.Node
|
|
// Changed counts edits not yet saved.
|
|
Changed int
|
|
}
|
|
|
|
// Open reads a file; a missing one is an empty document.
|
|
func Open(path string) (*Doc, error) {
|
|
d := &Doc{Path: path}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if len(bytes.TrimSpace(b)) > 0 {
|
|
if err := yaml.Unmarshal(b, &d.doc); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
}
|
|
if d.doc.Kind == 0 {
|
|
d.doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}}
|
|
}
|
|
d.root = d.doc.Content[0]
|
|
if d.root.Kind != yaml.MappingNode {
|
|
return nil, fmt.Errorf("%s is not a mapping at the top", path)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// Bytes is the document as it would be written.
|
|
func (d *Doc) Bytes() ([]byte, error) {
|
|
var b bytes.Buffer
|
|
enc := yaml.NewEncoder(&b)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(&d.doc); err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Bytes(), enc.Close()
|
|
}
|
|
|
|
// Node is the node at a path of keys and list indexes, or nil.
|
|
func (d *Doc) Node(path []string) *yaml.Node {
|
|
n := d.root
|
|
for _, p := range path {
|
|
n = child(n, p)
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func child(n *yaml.Node, key string) *yaml.Node {
|
|
switch n.Kind {
|
|
case yaml.MappingNode:
|
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
|
if n.Content[i].Value == key {
|
|
return n.Content[i+1]
|
|
}
|
|
}
|
|
case yaml.SequenceNode:
|
|
if i, err := strconv.Atoi(key); err == nil && i >= 0 && i < len(n.Content) {
|
|
return n.Content[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensure is the node at a path, made as mappings where there is none.
|
|
func (d *Doc) ensure(path []string) *yaml.Node {
|
|
n := d.root
|
|
for _, p := range path {
|
|
c := child(n, p)
|
|
if c == nil {
|
|
if n.Kind != yaml.MappingNode {
|
|
return nil
|
|
}
|
|
c = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: p}, c)
|
|
}
|
|
n = c
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Set writes one key's value: a scalar, a list of lines or a map of
|
|
// "key = value" lines, by its kind. It checks the value first.
|
|
func (d *Doc) Set(path []string, k Kind, value string) error {
|
|
if len(path) == 0 {
|
|
return fmt.Errorf("no key")
|
|
}
|
|
n, err := encode(k, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parent := d.ensure(path[:len(path)-1])
|
|
if parent == nil {
|
|
return fmt.Errorf("%s is not a block", strings.Join(path[:len(path)-1], "."))
|
|
}
|
|
key := path[len(path)-1]
|
|
if parent.Kind == yaml.SequenceNode {
|
|
i, _ := strconv.Atoi(key)
|
|
parent.Content[i] = n
|
|
} else if c := child(parent, key); c != nil {
|
|
// Kept in place, with whatever comment it had.
|
|
n.HeadComment, n.LineComment, n.FootComment = c.HeadComment, c.LineComment, c.FootComment
|
|
*c = *n
|
|
} else {
|
|
parent.Content = append(parent.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, n)
|
|
}
|
|
d.Changed++
|
|
return nil
|
|
}
|
|
|
|
// Unset removes a key, so its default applies.
|
|
func (d *Doc) Unset(path []string) {
|
|
if len(path) == 0 {
|
|
return
|
|
}
|
|
parent := d.Node(path[:len(path)-1])
|
|
if parent == nil {
|
|
return
|
|
}
|
|
key := path[len(path)-1]
|
|
switch parent.Kind {
|
|
case yaml.MappingNode:
|
|
for i := 0; i+1 < len(parent.Content); i += 2 {
|
|
if parent.Content[i].Value == key {
|
|
parent.Content = append(parent.Content[:i], parent.Content[i+2:]...)
|
|
d.Changed++
|
|
return
|
|
}
|
|
}
|
|
case yaml.SequenceNode:
|
|
if i, err := strconv.Atoi(key); err == nil && i < len(parent.Content) {
|
|
parent.Content = append(parent.Content[:i], parent.Content[i+1:]...)
|
|
d.Changed++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Append adds an empty block to the list at path, and returns its index.
|
|
func (d *Doc) Append(path []string) int {
|
|
parent := d.ensure(path[:len(path)-1])
|
|
key := path[len(path)-1]
|
|
seq := child(parent, key)
|
|
if seq == nil || seq.Kind != yaml.SequenceNode {
|
|
seq = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
if c := child(parent, key); c != nil {
|
|
*c = *seq
|
|
seq = c
|
|
} else {
|
|
parent.Content = append(parent.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, seq)
|
|
}
|
|
}
|
|
seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"})
|
|
d.Changed++
|
|
return len(seq.Content) - 1
|
|
}
|
|
|
|
// AddEntry adds an empty block under a name to the map at path.
|
|
func (d *Doc) AddEntry(path []string, name string) error {
|
|
m := d.ensure(path)
|
|
if m == nil || m.Kind != yaml.MappingNode {
|
|
return fmt.Errorf("%s is not a map", strings.Join(path, "."))
|
|
}
|
|
if child(m, name) != nil {
|
|
return fmt.Errorf("%s is there already", name)
|
|
}
|
|
m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name},
|
|
&yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"})
|
|
d.Changed++
|
|
return nil
|
|
}
|
|
|
|
// Len is how many entries a list or map at path has.
|
|
func (d *Doc) Len(path []string) int {
|
|
n := d.Node(path)
|
|
switch {
|
|
case n == nil:
|
|
return 0
|
|
case n.Kind == yaml.SequenceNode:
|
|
return len(n.Content)
|
|
case n.Kind == yaml.MappingNode:
|
|
return len(n.Content) / 2
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// MapKeys are a map's keys, in the file's order.
|
|
func (d *Doc) MapKeys(path []string) []string {
|
|
n := d.Node(path)
|
|
if n == nil || n.Kind != yaml.MappingNode {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
|
out = append(out, n.Content[i].Value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Text is a key's value as it is edited: a scalar as written, a list one
|
|
// item a line, a map "key = value" a line. ok is false for a key the file
|
|
// does not set.
|
|
func (d *Doc) Text(path []string) (string, bool) {
|
|
n := d.Node(path)
|
|
if n == nil {
|
|
return "", false
|
|
}
|
|
switch n.Kind {
|
|
case yaml.ScalarNode:
|
|
return n.Value, true
|
|
case yaml.SequenceNode:
|
|
var l []string
|
|
for _, c := range n.Content {
|
|
l = append(l, c.Value)
|
|
}
|
|
return strings.Join(l, "\n"), true
|
|
case yaml.MappingNode:
|
|
var l []string
|
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
|
l = append(l, n.Content[i].Value+" = "+n.Content[i+1].Value)
|
|
}
|
|
return strings.Join(l, "\n"), true
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
func encode(k Kind, value string) (*yaml.Node, error) {
|
|
v := strings.TrimSpace(value)
|
|
scalar := func(tag string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: v} }
|
|
switch k {
|
|
case Int:
|
|
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
|
return nil, fmt.Errorf("%q is not a whole number", v)
|
|
}
|
|
return scalar("!!int"), nil
|
|
case Float:
|
|
if _, err := strconv.ParseFloat(v, 64); err != nil {
|
|
return nil, fmt.Errorf("%q is not a number", v)
|
|
}
|
|
return scalar("!!float"), nil
|
|
case Bool:
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q is not true or false", v)
|
|
}
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(b)}, nil
|
|
case Duration:
|
|
if _, err := time.ParseDuration(v); err != nil {
|
|
return nil, fmt.Errorf("%q is not a duration, like 30s, 5m or 2h", v)
|
|
}
|
|
return scalar("!!str"), nil
|
|
case TextList:
|
|
seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
for _, l := range splitLines(value) {
|
|
seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: l})
|
|
}
|
|
return seq, nil
|
|
case TextMap:
|
|
m := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
for _, l := range splitLines(value) {
|
|
k, val, ok := strings.Cut(l, "=")
|
|
if !ok {
|
|
return nil, fmt.Errorf("%q is not key = value", l)
|
|
}
|
|
m.Content = append(m.Content,
|
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: strings.TrimSpace(k)},
|
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: strings.TrimSpace(val)})
|
|
}
|
|
return m, nil
|
|
case Block, BlockList, BlockMap:
|
|
return nil, fmt.Errorf("a block is edited key by key")
|
|
}
|
|
return scalar("!!str"), nil
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var out []string
|
|
for _, l := range strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == ',' }) {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Save checks the document with check — a loader that reads and validates
|
|
// a file — and writes it, the old file kept beside it. Nothing is written
|
|
// when the check fails.
|
|
func (d *Doc) Save(check func(path string) error) (string, error) {
|
|
b, err := d.Bytes()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tmp := d.Path + ".check"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(tmp)
|
|
if check != nil {
|
|
if err := check(tmp); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
backup := ""
|
|
if info, err := os.Stat(d.Path); err == nil {
|
|
mode = info.Mode().Perm()
|
|
old, err := os.ReadFile(d.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
backup = fmt.Sprintf("%s.bak-%s", d.Path, time.Now().Format("20060102-150405"))
|
|
if err := os.WriteFile(backup, old, mode); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if err := os.WriteFile(d.Path+".new", b, mode); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Rename(d.Path+".new", d.Path); err != nil {
|
|
return "", err
|
|
}
|
|
d.Changed = 0
|
|
return backup, nil
|
|
}
|
|
|
|
// Effective is the value a loaded configuration has at a path of koanf
|
|
// keys — what applies, default included — as text.
|
|
func Effective(cfg any, path []string) (string, bool) {
|
|
v := reflect.ValueOf(cfg)
|
|
for _, p := range path {
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return "", false
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
switch v.Kind() {
|
|
case reflect.Struct:
|
|
found := false
|
|
t := v.Type()
|
|
for i := 0; i < t.NumField(); i++ {
|
|
if tag, _, _ := strings.Cut(t.Field(i).Tag.Get("koanf"), ","); tag == p {
|
|
v, found = v.Field(i), true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return "", false
|
|
}
|
|
case reflect.Slice:
|
|
i, err := strconv.Atoi(p)
|
|
if err != nil || i >= v.Len() {
|
|
return "", false
|
|
}
|
|
v = v.Index(i)
|
|
case reflect.Map:
|
|
mv := v.MapIndex(reflect.ValueOf(p))
|
|
if !mv.IsValid() {
|
|
return "", false
|
|
}
|
|
v = mv
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return "", true
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
switch v.Kind() {
|
|
case reflect.Slice:
|
|
var l []string
|
|
for i := 0; i < v.Len(); i++ {
|
|
l = append(l, fmt.Sprint(v.Index(i).Interface()))
|
|
}
|
|
return strings.Join(l, ", "), true
|
|
case reflect.Struct, reflect.Map:
|
|
return "", true
|
|
}
|
|
if d, ok := v.Interface().(time.Duration); ok {
|
|
return d.String(), true
|
|
}
|
|
return fmt.Sprint(v.Interface()), true
|
|
}
|