Przeglądaj źródła

fix(routing): harmonize fallback interfaces, status flags & modals across all protocols (v1.8.17)

Proxmox Router 18 godzin temu
rodzic
commit
147a7934ac

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.16
+1.8.17

BIN
bin/debtik


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 22 - 7
internal/cli/executor.go

@@ -2785,11 +2785,16 @@ func (e *Executor) handleRoutingRIP(parts []string, user *auth.PublicUser) strin
 func (e *Executor) handleRIPInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListRIPInstances()
-		tbl := NewTable("#", "NAME", "VERSION", "REDISTRIBUTE", "UPDATE-TIME", "STATUS")
+		tbl := NewTable("#", "FLAGS", "NAME", "VERSION", "REDISTRIBUTE", "UPDATE-TIME", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
+			flags := "I"
+			if inst.Running {
+				flags = "R"
+			}
 			if inst.Disabled {
 				st = "disabled"
+				flags = "X"
 			}
 			var redist []string
 			if inst.RedistributeConnected {
@@ -2811,7 +2816,7 @@ func (e *Executor) handleRIPInstance(parts []string, user *auth.PublicUser) stri
 			if len(redist) > 0 {
 				redistStr = strings.Join(redist, ",")
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), inst.Name, fmt.Sprintf("%d", inst.Version), redistStr, fmt.Sprintf("%ds", inst.UpdateTime), st)
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, fmt.Sprintf("%d", inst.Version), redistStr, fmt.Sprintf("%ds", inst.UpdateTime), st)
 		}
 		return tbl.String()
 	}
@@ -3471,7 +3476,7 @@ func (e *Executor) handleBabelInstance(parts []string, user *auth.PublicUser) st
 		tbl := NewTable("#", "FLAGS", "NAME", "ROUTER-ID", "REDISTRIBUTE", "METRIC", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
-			flags := ""
+			flags := "I"
 			if inst.Running {
 				flags = "R"
 			}
@@ -3761,13 +3766,18 @@ func (e *Executor) handleRoutingRAdv(parts []string, user *auth.PublicUser) stri
 func (e *Executor) handleRAdvInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListRAdvInstances()
-		tbl := NewTable("#", "NAME", "STATUS")
+		tbl := NewTable("#", "FLAGS", "NAME", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
+			flags := "I"
+			if inst.Running {
+				flags = "R"
+			}
 			if inst.Disabled {
 				st = "disabled"
+				flags = "X"
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), inst.Name, st)
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, st)
 		}
 		return tbl.String()
 	}
@@ -4014,11 +4024,16 @@ func (e *Executor) handleRoutingOSPF(parts []string, user *auth.PublicUser) stri
 func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListOSPFInstances()
-		tbl := NewTable("#", "NAME", "ROUTER-ID", "VERSION", "REDISTRIBUTE", "DEFAULT-METRIC", "STATUS")
+		tbl := NewTable("#", "FLAGS", "NAME", "ROUTER-ID", "VERSION", "REDISTRIBUTE", "DEFAULT-METRIC", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
+			flags := "I"
+			if inst.Running {
+				flags = "R"
+			}
 			if inst.Disabled {
 				st = "disabled"
+				flags = "X"
 			}
 			var redist []string
 			if inst.RedistributeConnected {
@@ -4045,7 +4060,7 @@ func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) str
 			if inst.DefaultMetric > 0 {
 				metricStr = fmt.Sprintf("%d", inst.DefaultMetric)
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), inst.Name, inst.RouterID, fmt.Sprintf("%d", inst.Version), redistStr, metricStr, st)
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, inst.RouterID, fmt.Sprintf("%d", inst.Version), redistStr, metricStr, st)
 		}
 		return tbl.String()
 	}

+ 106 - 18
internal/dynamicrouting/manager.go

@@ -781,6 +781,7 @@ func (m *Manager) generateBirdConfig() string {
 			usedNbrIDs := make(map[string]bool)
 
 			// Interface templates matching this area
+			hasAreaTemplate := false
 			for _, iface := range m.config.OSPFInterfaces {
 				if iface.Disabled {
 					continue
@@ -788,6 +789,7 @@ func (m *Manager) generateBirdConfig() string {
 				if iface.Area != "" && !strings.EqualFold(iface.Area, area.Name) && !strings.EqualFold(iface.Area, area.AreaID) && iface.Area != "Default" {
 					continue
 				}
+				hasAreaTemplate = true
 
 				ifaceName := strings.TrimSpace(iface.Interfaces)
 				if ifaceName == "" {
@@ -912,6 +914,14 @@ func (m *Manager) generateBirdConfig() string {
 				sb.WriteString("        };\n")
 			}
 
+			// Fallback wildcard interface template if none configured for this area
+			if !hasAreaTemplate && len(remainingNbrs) == 0 {
+				sb.WriteString("        interface \"*\" {\n")
+				sb.WriteString("            cost 1;\n")
+				sb.WriteString("            type bcast;\n")
+				sb.WriteString("        };\n")
+			}
+
 			sb.WriteString("    };\n")
 		}
 
@@ -1729,20 +1739,36 @@ func (m *Manager) ListOSPFInstances() []OSPFInstance {
 
 	out, err := birdcExec("show protocols")
 	protocols := map[string]bool{}
+	birdRunning := false
 	if err == nil {
+		birdRunning = strings.Contains(string(out), "BIRD")
 		for _, line := range strings.Split(string(out), "\n") {
 			fields := strings.Fields(line)
-			if len(fields) >= 4 && fields[1] == "OSPF" {
+			if len(fields) >= 4 && (fields[1] == "OSPF" || strings.HasPrefix(fields[0], "ospf_")) {
 				protocols[fields[0]] = fields[3] == "up"
 			}
 		}
 	}
 
-	res := make([]OSPFInstance, len(m.config.OSPFInstances))
-	for i, inst := range m.config.OSPFInstances {
+	instances := m.config.OSPFInstances
+	if len(instances) == 0 && (m.config.OSPFInstance.Name != "" || m.config.OSPFInstance.RouterID != "") {
+		legacy := m.config.OSPFInstance
+		if legacy.Name == "" {
+			legacy.Name = "Default"
+		}
+		if legacy.ID == "" {
+			legacy.ID = "ospf-inst-default"
+		}
+		instances = []OSPFInstance{legacy}
+	}
+
+	res := make([]OSPFInstance, len(instances))
+	for i, inst := range instances {
 		instCopy := inst
+		instCopy.Enabled = !inst.Disabled
 		instCopy.Running = false
-		if protocols[sanitizeIdentifier("ospf", inst.Name)] && !inst.Disabled {
+		isUp := protocols[sanitizeIdentifier("ospf", inst.Name)] || (birdRunning && !inst.Disabled)
+		if isUp && !inst.Disabled {
 			instCopy.Running = true
 		}
 		res[i] = instCopy
@@ -1769,10 +1795,11 @@ func (m *Manager) SaveOSPFInstance(inst *OSPFInstance) error {
 	if inst.RoutingTable == "" {
 		inst.RoutingTable = "main"
 	}
+	inst.Enabled = !inst.Disabled
 
 	found := false
 	for i, it := range m.config.OSPFInstances {
-		if it.ID == inst.ID {
+		if it.ID == inst.ID || strings.EqualFold(it.Name, inst.Name) {
 			m.config.OSPFInstances[i] = *inst
 			found = true
 			break
@@ -1797,7 +1824,7 @@ func (m *Manager) DeleteOSPFInstance(id string) error {
 
 	var newInst []OSPFInstance
 	for _, it := range m.config.OSPFInstances {
-		if it.ID != id {
+		if it.ID != id && !strings.EqualFold(it.Name, id) {
 			newInst = append(newInst, it)
 		}
 	}
@@ -1814,12 +1841,16 @@ func (m *Manager) SetOSPFInstanceState(id string, disabled bool) error {
 	defer m.mu.Unlock()
 
 	for i := range m.config.OSPFInstances {
-		if m.config.OSPFInstances[i].ID == id {
+		if m.config.OSPFInstances[i].ID == id || strings.EqualFold(m.config.OSPFInstances[i].Name, id) {
 			m.config.OSPFInstances[i].Disabled = disabled
+			m.config.OSPFInstances[i].Enabled = !disabled
 			break
 		}
 	}
 
+	m.config.OSPFInstance.Disabled = disabled
+	m.config.OSPFInstance.Enabled = !disabled
+
 	if err := m.save(); err != nil {
 		return err
 	}
@@ -2799,13 +2830,26 @@ func (m *Manager) ListRIPInstances() []RIPInstance {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
 
-	out, err := birdcExec("show status")
-	birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running")
+	out, err := birdcExec("show protocols")
+	protocols := map[string]bool{}
+	birdRunning := false
+	if err == nil {
+		birdRunning = strings.Contains(string(out), "BIRD")
+		for _, line := range strings.Split(string(out), "\n") {
+			fields := strings.Fields(line)
+			if len(fields) >= 4 && (fields[1] == "RIP" || strings.HasPrefix(fields[0], "rip_")) {
+				protocols[fields[0]] = fields[3] == "up"
+			}
+		}
+	}
 
 	res := make([]RIPInstance, len(m.config.RIPInstances))
 	copy(res, m.config.RIPInstances)
 	for i := range res {
-		res[i].Running = birdRunning && (res[i].Enabled || !res[i].Disabled) && !res[i].Disabled
+		protoName := sanitizeIdentifier("rip", fmt.Sprintf("%s_%d", res[i].Name, i))
+		isProtoUp := protocols[protoName] || protocols[sanitizeIdentifier("rip", res[i].Name)]
+		res[i].Running = (isProtoUp || (birdRunning && (res[i].Enabled || !res[i].Disabled))) && !res[i].Disabled
+		res[i].Enabled = !res[i].Disabled
 	}
 	return res
 }
@@ -3328,13 +3372,26 @@ func (m *Manager) ListBabelInstances() []BabelInstance {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
 
-	out, err := birdcExec("show status")
-	birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running")
+	out, err := birdcExec("show protocols")
+	protocols := map[string]bool{}
+	birdRunning := false
+	if err == nil {
+		birdRunning = strings.Contains(string(out), "BIRD")
+		for _, line := range strings.Split(string(out), "\n") {
+			fields := strings.Fields(line)
+			if len(fields) >= 4 && (fields[1] == "Babel" || strings.HasPrefix(fields[0], "babel_")) {
+				protocols[fields[0]] = fields[3] == "up"
+			}
+		}
+	}
 
 	res := make([]BabelInstance, len(m.config.BabelInstances))
 	copy(res, m.config.BabelInstances)
 	for i := range res {
-		res[i].Running = birdRunning && (res[i].Enabled || !res[i].Disabled) && !res[i].Disabled
+		protoName := sanitizeIdentifier("babel", fmt.Sprintf("%s_%d", res[i].Name, i))
+		isProtoUp := protocols[protoName] || protocols[sanitizeIdentifier("babel", res[i].Name)]
+		res[i].Running = (isProtoUp || (birdRunning && (res[i].Enabled || !res[i].Disabled))) && !res[i].Disabled
+		res[i].Enabled = !res[i].Disabled
 	}
 	return res
 }
@@ -3699,13 +3756,26 @@ func (m *Manager) ListRAdvInstances() []RAdvInstance {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
 
-	out, err := birdcExec("show status")
-	birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running")
+	out, err := birdcExec("show protocols")
+	protocols := map[string]bool{}
+	birdRunning := false
+	if err == nil {
+		birdRunning = strings.Contains(string(out), "BIRD")
+		for _, line := range strings.Split(string(out), "\n") {
+			fields := strings.Fields(line)
+			if len(fields) >= 4 && (fields[1] == "RAdv" || strings.HasPrefix(fields[0], "radv_")) {
+				protocols[fields[0]] = fields[3] == "up"
+			}
+		}
+	}
 
 	res := make([]RAdvInstance, len(m.config.RAdvInstances))
 	copy(res, m.config.RAdvInstances)
 	for i := range res {
-		res[i].Running = birdRunning && (res[i].Enabled || !res[i].Disabled) && !res[i].Disabled
+		protoName := sanitizeIdentifier("radv", fmt.Sprintf("%s_%d", res[i].Name, i))
+		isProtoUp := protocols[protoName] || protocols[sanitizeIdentifier("radv", res[i].Name)]
+		res[i].Running = (isProtoUp || (birdRunning && (res[i].Enabled || !res[i].Disabled))) && !res[i].Disabled
+		res[i].Enabled = !res[i].Disabled
 	}
 	return res
 }
@@ -3876,9 +3946,27 @@ func (m *Manager) GetSummary() DynamicRoutingSummary {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
 
+	ospfEnabled := (m.config.OSPFInstance.Enabled || !m.config.OSPFInstance.Disabled) && !m.config.OSPFInstance.Disabled
+	for _, inst := range m.config.OSPFInstances {
+		if (inst.Enabled || !inst.Disabled) && !inst.Disabled {
+			ospfEnabled = true
+			break
+		}
+	}
+
+	bgpEnabled := m.config.BGPInstance.Enabled
+	if !bgpEnabled && len(m.config.BGPPeers) > 0 {
+		for _, p := range m.config.BGPPeers {
+			if !p.Disabled {
+				bgpEnabled = true
+				break
+			}
+		}
+	}
+
 	summary := DynamicRoutingSummary{
-		BGPEnabled:         m.config.BGPInstance.Enabled,
-		OSPFEnabled:        m.config.OSPFInstance.Enabled,
+		BGPEnabled:         bgpEnabled,
+		OSPFEnabled:        ospfEnabled,
 		BGPPeerCount:       len(m.config.BGPPeers),
 		RoutingFilterCount: len(m.config.RoutingFilters),
 	}

+ 9 - 2
internal/dynamicrouting/protocols_test.go

@@ -319,13 +319,20 @@ func TestBFDAndRIPAndRAdvManagement(t *testing.T) {
 		t.Errorf("Expected protocol radv in config, got:\n%s", cfgAll)
 	}
 
+	// 7. Verify OSPF fallback interface when no templates exist
+	mgr.config.OSPFInterfaces = nil
+	cfgOspfFallback := mgr.generateBirdConfig()
+	if !strings.Contains(cfgOspfFallback, `interface "*"`) {
+		t.Errorf("Expected fallback wildcard interface for OSPF area, got:\n%s", cfgOspfFallback)
+	}
+
 	if _, err := exec.LookPath("bird"); err == nil {
 		tmpFile, err := os.CreateTemp("", "bird_all_val_*.conf")
 		if err != nil {
 			t.Fatalf("Failed to create temp file: %v", err)
 		}
 		defer os.Remove(tmpFile.Name())
-		if _, err := tmpFile.WriteString(cfgAll); err != nil {
+		if _, err := tmpFile.WriteString(cfgOspfFallback); err != nil {
 			t.Fatalf("Failed to write temp file: %v", err)
 		}
 		tmpFile.Close()
@@ -333,7 +340,7 @@ func TestBFDAndRIPAndRAdvManagement(t *testing.T) {
 		cmd := exec.Command("bird", "-p", "-c", tmpFile.Name())
 		out, err := cmd.CombinedOutput()
 		if err != nil {
-			t.Fatalf("bird -p validation failed on all protocols config: %s\nConfig:\n%s", string(out), cfgAll)
+			t.Fatalf("bird -p validation failed on all protocols config: %s\nConfig:\n%s", string(out), cfgOspfFallback)
 		}
 	}
 }

+ 92 - 12
internal/web/static/app.js

@@ -10244,7 +10244,11 @@ function renderOspfInstances() {
   tbody.innerHTML = list.map((inst, idx) => {
     const isSel = inst.id === selId;
     const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-sky-400' : 'hover:bg-[#1a2636] text-slate-300';
-    const flagD = inst.disabled ? '<span class="text-rose-400 font-bold" title="Disabled">X</span>' : '';
+    const flagD = inst.disabled
+      ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>'
+      : (inst.running
+        ? '<span class="text-emerald-400 font-bold" title="Running (R)">R</span>'
+        : '<span class="text-amber-400 font-bold" title="Inactive (I)">I</span>');
 
     const redist = [];
     if (inst.redistribute_connected) redist.push('conn');
@@ -10263,7 +10267,7 @@ function renderOspfInstances() {
     return `
       <tr class="${selClass} cursor-pointer transition select-none" onclick="selectOspfRow('instances', '${inst.id}')" ondblclick="openEditOspfInstanceModal('${inst.id}')">
         <td class="py-1.5 px-3 w-8 text-center text-slate-500">${idx}</td>
-        <td class="py-1.5 px-3 w-12 text-center text-sky-400">${flagD || '🔀'}</td>
+        <td class="py-1.5 px-3 w-12 text-center">${flagD}</td>
         <td class="py-1.5 px-3 text-slate-400 italic">${escapeHtml(inst.comment || '')}</td>
         <td class="py-1.5 px-3 font-semibold text-white flex items-center gap-1.5">
           <span class="text-sky-400">↳</span> <span>${escapeHtml(inst.name || 'Default')}</span>
@@ -10642,10 +10646,22 @@ function populateBgpRoutingTables() {
   sel.value = cur;
 }
 
+function populateBgpLocalAddrDatalist() {
+  const dlBgp = document.getElementById('bgp-peer-local-addr-datalist');
+  if (!dlBgp) return;
+  const addrs = [];
+  (state.interfaces || []).forEach(iface => {
+    if (iface.ip) addrs.push(iface.ip.split('/')[0]);
+    (iface.addresses || []).forEach(a => addrs.push(a.split('/')[0]));
+  });
+  dlBgp.innerHTML = Array.from(new Set(addrs.filter(Boolean))).map(ip => `<option value="${escapeHtml(ip)}">`).join('');
+}
+
 function openAddBgpPeerModal() {
   document.getElementById('bgp-peer-form').reset();
   document.getElementById('modal-bgp-peer-id').value = '';
   document.getElementById('bgp-peer-modal-title').innerText = 'New BGP Connection...';
+  populateBgpLocalAddrDatalist();
   
   // General Tab
   document.getElementById('modal-bgp-peer-enabled').checked = true;
@@ -10715,6 +10731,7 @@ function openEditBgpPeerModal(id) {
   if (!p) return;
 
   populateFilterChainsDatalist();
+  populateBgpLocalAddrDatalist();
 
   document.getElementById('modal-bgp-peer-id').value = p.id;
   document.getElementById('bgp-peer-modal-title').innerText = p.name || p.id;
@@ -11479,6 +11496,11 @@ function openOspfTemplateModal(id = null) {
   populateTemplateAreaDropdowns();
   switchOspfTemplateTab('general');
 
+  const dl = document.getElementById('ospf-tmpl-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*"><option value="all">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
+
   const tmpl = (state.ospfTemplates || []).find(t => t.id === id) || {
     id: '',
     interfaces: '',
@@ -19654,7 +19676,11 @@ function renderRipInstances() {
   tbody.innerHTML = list.map((inst, idx) => {
     const isSel = inst.id === selId;
     const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-rose-400' : 'hover:bg-[#1a2636] text-slate-300';
-    const flagD = inst.disabled ? '<span class="text-rose-400 font-bold" title="Disabled">X</span>' : '<span class="text-emerald-400">R</span>';
+    const flagD = inst.disabled
+      ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>'
+      : (inst.running
+        ? '<span class="text-emerald-400 font-bold" title="Running (R)">R</span>'
+        : '<span class="text-amber-400 font-bold" title="Inactive (I)">I</span>');
     const redist = [];
     if (inst.redistribute_connected) redist.push('conn');
     if (inst.redistribute_static) redist.push('static');
@@ -19847,8 +19873,19 @@ async function removeSelectedRipInstance() {
 function openRipInterfaceModal() {
   document.getElementById('rip-interface-modal-title').innerText = 'New RIP Interface';
   document.getElementById('modal-rip-iface-id').value = '';
-  document.getElementById('modal-rip-iface-pattern').value = '';
-  document.getElementById('modal-rip-iface-instance').value = 'rip1';
+
+  const dl = document.getElementById('rip-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
+  const instSel = document.getElementById('modal-rip-iface-instance');
+  if (instSel && instSel.tagName === 'SELECT') {
+    instSel.innerHTML = (state.ripInstances || []).map(i => `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)}</option>`).join('');
+    if (instSel.innerHTML === '') instSel.innerHTML = '<option value="default">default</option>';
+  }
+
+  document.getElementById('modal-rip-iface-pattern').value = '*';
+  if (instSel) instSel.value = (state.ripInstances && state.ripInstances.length > 0) ? state.ripInstances[0].name : 'default';
   document.getElementById('modal-rip-iface-mode').value = 'multicast';
   document.getElementById('modal-rip-iface-metric').value = '1';
   document.getElementById('modal-rip-iface-comment').value = '';
@@ -19863,8 +19900,21 @@ function openEditRipInterfaceModal(id) {
   if (!iface) return;
   document.getElementById('rip-interface-modal-title').innerText = `Edit RIP Interface: ${iface.interface}`;
   document.getElementById('modal-rip-iface-id').value = iface.id;
-  document.getElementById('modal-rip-iface-pattern').value = iface.interface || '';
-  document.getElementById('modal-rip-iface-instance').value = iface.instance || '';
+
+  const dl = document.getElementById('rip-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
+  const instSel = document.getElementById('modal-rip-iface-instance');
+  if (instSel && instSel.tagName === 'SELECT') {
+    instSel.innerHTML = (state.ripInstances || []).map(i => `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)}</option>`).join('');
+    if (instSel.innerHTML === '') instSel.innerHTML = '<option value="default">default</option>';
+    instSel.value = iface.instance || 'default';
+  } else if (instSel) {
+    instSel.value = iface.instance || '';
+  }
+
+  document.getElementById('modal-rip-iface-pattern').value = iface.interface || '*';
   document.getElementById('modal-rip-iface-mode').value = iface.mode || 'multicast';
   document.getElementById('modal-rip-iface-metric').value = iface.metric || 1;
   document.getElementById('modal-rip-iface-comment').value = iface.comment || '';
@@ -20788,7 +20838,11 @@ function renderRAdvInstances() {
   tbody.innerHTML = list.map((inst, idx) => {
     const isSel = inst.id === selId;
     const selClass = isSel ? 'bg-[#1b2f48] text-white border-l-2 border-blue-400' : 'hover:bg-[#1a2636] text-slate-300';
-    const flagD = inst.disabled ? '<span class="text-rose-400 font-bold">X</span>' : '<span class="text-emerald-400">R</span>';
+    const flagD = inst.disabled
+      ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>'
+      : (inst.running
+        ? '<span class="text-emerald-400 font-bold" title="Running (R)">R</span>'
+        : '<span class="text-amber-400 font-bold" title="Inactive (I)">I</span>');
     return `
       <tr class="${selClass} cursor-pointer transition select-none" onclick="selectRadvRow('instances', '${inst.id}')" ondblclick="openEditRAdvInstanceModal('${inst.id}')">
         <td class="py-1.5 px-3 w-8 text-center text-slate-500">${idx}</td>
@@ -20908,8 +20962,21 @@ async function removeSelectedRadvInstance() {
 function openRAdvInterfaceModal() {
   document.getElementById('radv-interface-modal-title').innerText = 'New RAdv Interface';
   document.getElementById('modal-radv-iface-id').value = '';
-  document.getElementById('modal-radv-iface-interface').value = '';
-  document.getElementById('modal-radv-iface-instance').value = 'radv1';
+
+  const dl = document.getElementById('radv-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
+  const instSel = document.getElementById('modal-radv-iface-instance');
+  if (instSel && instSel.tagName === 'SELECT') {
+    instSel.innerHTML = (state.radvInstances || []).map(i => `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)}</option>`).join('');
+    if (instSel.innerHTML === '') instSel.innerHTML = '<option value="default">default</option>';
+    instSel.value = (state.radvInstances && state.radvInstances.length > 0) ? state.radvInstances[0].name : 'default';
+  } else if (instSel) {
+    instSel.value = 'default';
+  }
+
+  document.getElementById('modal-radv-iface-interface').value = '*';
   document.getElementById('modal-radv-iface-min-ra').value = '200';
   document.getElementById('modal-radv-iface-max-ra').value = '600';
   document.getElementById('modal-radv-iface-lifetime').value = '1800';
@@ -20926,8 +20993,21 @@ function openEditRAdvInterfaceModal(id) {
   if (!iface) return;
   document.getElementById('radv-interface-modal-title').innerText = `Edit RAdv Interface: ${iface.interface}`;
   document.getElementById('modal-radv-iface-id').value = iface.id;
-  document.getElementById('modal-radv-iface-interface').value = iface.interface || '';
-  document.getElementById('modal-radv-iface-instance').value = iface.instance || '';
+
+  const dl = document.getElementById('radv-iface-datalist');
+  if (dl) {
+    dl.innerHTML = '<option value="*">' + (state.interfaces || []).map(i => `<option value="${escapeHtml(i.name)}">`).join('');
+  }
+  const instSel = document.getElementById('modal-radv-iface-instance');
+  if (instSel && instSel.tagName === 'SELECT') {
+    instSel.innerHTML = (state.radvInstances || []).map(i => `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)}</option>`).join('');
+    if (instSel.innerHTML === '') instSel.innerHTML = '<option value="default">default</option>';
+    instSel.value = iface.instance || 'default';
+  } else if (instSel) {
+    instSel.value = iface.instance || '';
+  }
+
+  document.getElementById('modal-radv-iface-interface').value = iface.interface || '*';
   document.getElementById('modal-radv-iface-min-ra').value = iface.min_ra_interval || 200;
   document.getElementById('modal-radv-iface-max-ra').value = iface.max_ra_interval || 600;
   document.getElementById('modal-radv-iface-lifetime').value = iface.default_lifetime || 1800;

+ 13 - 7
internal/web/static/index.html

@@ -10871,7 +10871,8 @@
                 <label class="text-slate-400 font-medium">Local Address &amp; Port</label>
                 <div class="col-span-2 grid grid-cols-3 gap-2">
                   <div class="col-span-2">
-                    <input type="text" id="modal-bgp-peer-local-addr" placeholder="Optional bind IP (e.g. 192.168.129.226)" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                    <input type="text" id="modal-bgp-peer-local-addr" list="bgp-peer-local-addr-datalist" placeholder="Optional bind IP (e.g. 192.168.129.226)" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                    <datalist id="bgp-peer-local-addr-datalist"></datalist>
                   </div>
                   <div>
                     <input type="number" id="modal-bgp-peer-local-port" placeholder="Port" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
@@ -11434,7 +11435,8 @@
               <div class="grid grid-cols-3 items-center gap-3">
                 <label class="text-slate-400 font-medium">Interfaces</label>
                 <div class="col-span-2">
-                  <input type="text" id="modal-ospf-tmpl-interfaces" placeholder="e.g. ether1, wlan1 or all" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                  <input type="text" id="modal-ospf-tmpl-interfaces" list="ospf-tmpl-iface-datalist" placeholder="e.g. ether1, wlan1 or all" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                  <datalist id="ospf-tmpl-iface-datalist"></datalist>
                   <p class="text-[10px] text-slate-500 mt-0.5">Physical/VLAN/tunnel interfaces or leave empty to match by networks</p>
                 </div>
               </div>
@@ -11958,11 +11960,13 @@
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1">Interface Pattern *</label>
-            <input type="text" id="modal-rip-iface-pattern" required placeholder="e.g. eth0, lan*, or *" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <input type="text" id="modal-rip-iface-pattern" list="rip-iface-datalist" required placeholder="e.g. eth0, lan*, or *" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <datalist id="rip-iface-datalist"></datalist>
           </div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1">RIP Instance</label>
-            <input type="text" id="modal-rip-iface-instance" placeholder="rip1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <select id="modal-rip-iface-instance" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            </select>
           </div>
         </div>
         <div class="grid grid-cols-2 gap-4">
@@ -12285,11 +12289,13 @@
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1">Interface *</label>
-            <input type="text" id="modal-radv-iface-interface" required placeholder="e.g. eth0 or lan0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <input type="text" id="modal-radv-iface-interface" list="radv-iface-datalist" required placeholder="e.g. eth0 or lan0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <datalist id="radv-iface-datalist"></datalist>
           </div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1">Instance</label>
-            <input type="text" id="modal-radv-iface-instance" placeholder="radv1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <select id="modal-radv-iface-instance" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            </select>
           </div>
         </div>
         <div class="grid grid-cols-2 gap-4">
@@ -13683,6 +13689,6 @@
   </div>
 
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=25.0"></script>
+  <script src="app.js?v=26.0"></script>
 </body>
 </html>