瀏覽代碼

feat(interfaces): add custom Role Tags management and interface assignment

Proxmox Router 1 周之前
父節點
當前提交
21c076f230
共有 5 個文件被更改,包括 660 次插入 和 60 次删除
  1. 41 0
      internal/api/api.go
  2. 170 3
      internal/network/metadata.go
  3. 17 0
      internal/network/types.go
  4. 276 8
      internal/web/static/app.js
  5. 156 49
      internal/web/static/index.html

+ 41 - 0
internal/api/api.go

@@ -129,6 +129,11 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("GET /api/interfaces/traffic/stream", s.handleInterfacesTrafficStream)
 	s.mux.HandleFunc("GET /api/interfaces/traffic/rates", s.handleInterfacesTrafficRates)
 	s.mux.HandleFunc("GET /api/interfaces/rates", s.handleInterfacesTrafficRates)
+	s.mux.HandleFunc("GET /api/roles", s.handleListRoleTags)
+	s.mux.HandleFunc("POST /api/roles", s.handleSaveRoleTag)
+	s.mux.HandleFunc("PUT /api/roles/{id}", s.handleSaveRoleTag)
+	s.mux.HandleFunc("DELETE /api/roles/{id}", s.handleDeleteRoleTag)
+	s.mux.HandleFunc("GET /api/interfaces/roles", s.handleListRoleTags)
 	s.mux.HandleFunc("GET /api/interfaces/{name}", s.handleGetInterface)
 	s.mux.HandleFunc("POST /api/interfaces/{name}/state", s.handleSetInterfaceState)
 	s.mux.HandleFunc("POST /api/interfaces/{name}/mtu", s.handleSetInterfaceMTU)
@@ -638,6 +643,42 @@ func (s *Server) handleSetInterfaceMetadata(w http.ResponseWriter, r *http.Reque
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "role": req.Role, "description": req.Description})
 }
 
+func (s *Server) handleListRoleTags(w http.ResponseWriter, r *http.Request) {
+	roles := s.metaMgr.GetRolesWithUsage()
+	writeJSON(w, http.StatusOK, roles)
+}
+
+func (s *Server) handleSaveRoleTag(w http.ResponseWriter, r *http.Request) {
+	var req network.RoleTag
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	id := r.PathValue("id")
+	if id != "" {
+		req.ID = id
+	}
+	saved, err := s.metaMgr.SaveRole(req)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, saved)
+}
+
+func (s *Server) handleDeleteRoleTag(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeError(w, http.StatusBadRequest, "missing role ID")
+		return
+	}
+	if err := s.metaMgr.DeleteRole(id); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
+}
+
 func (s *Server) handleRenameInterface(w http.ResponseWriter, r *http.Request) {
 	name := r.PathValue("name")
 	var req struct {

+ 170 - 3
internal/network/metadata.go

@@ -2,13 +2,25 @@ package network
 
 import (
 	"encoding/json"
+	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
+	"time"
 )
 
 var defaultMetadataPath = "/etc/proxmox-router/metadata.json"
 
+var defaultRoles = []RoleTag{
+	{ID: "wan", Name: "WAN", Color: "amber", Description: "Public internet uplink interfaces", Builtin: true},
+	{ID: "lan", Name: "LAN", Color: "blue", Description: "Local area network", Builtin: true},
+	{ID: "dmz", Name: "DMZ", Color: "purple", Description: "Demilitarized zone / Public services", Builtin: true},
+	{ID: "guest", Name: "GUEST", Color: "cyan", Description: "Isolated guest network", Builtin: true},
+	{ID: "iot", Name: "IOT", Color: "emerald", Description: "Smart devices & sensors", Builtin: true},
+	{ID: "mgmt", Name: "MGMT", Color: "rose", Description: "Management & OOB network", Builtin: true},
+}
+
 // MetadataManager manages user annotations for interfaces.
 type MetadataManager struct {
 	mu       sync.RWMutex
@@ -36,16 +48,26 @@ func (mm *MetadataManager) load() {
 	defer mm.mu.Unlock()
 
 	data, err := os.ReadFile(mm.filePath)
-	if err != nil {
-		return
+	if err == nil {
+		_ = json.Unmarshal(data, &mm.store)
 	}
-	_ = json.Unmarshal(data, &mm.store)
 	if mm.store.Interfaces == nil {
 		mm.store.Interfaces = make(map[string]InterfaceMetadata)
 	}
+	if len(mm.store.Roles) == 0 {
+		mm.store.Roles = make([]RoleTag, len(defaultRoles))
+		copy(mm.store.Roles, defaultRoles)
+		_ = mm.saveLocked()
+	}
 }
 
 func (mm *MetadataManager) save() error {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+	return mm.saveLocked()
+}
+
+func (mm *MetadataManager) saveLocked() error {
 	dir := filepath.Dir(mm.filePath)
 	if err := os.MkdirAll(dir, 0755); err != nil {
 		return err
@@ -59,6 +81,151 @@ func (mm *MetadataManager) save() error {
 	return os.WriteFile(mm.filePath, data, 0644)
 }
 
+// GetRolesWithUsage returns all role tags with assigned interface details.
+func (mm *MetadataManager) GetRolesWithUsage() []RoleTagWithUsage {
+	mm.mu.RLock()
+	defer mm.mu.RUnlock()
+
+	roles := mm.store.Roles
+	if len(roles) == 0 {
+		roles = defaultRoles
+	}
+
+	result := make([]RoleTagWithUsage, len(roles))
+	for i, r := range roles {
+		ru := RoleTagWithUsage{
+			RoleTag:    r,
+			Interfaces: make([]string, 0),
+		}
+
+		for ifaceName, meta := range mm.store.Interfaces {
+			if strings.EqualFold(meta.Role, r.Name) || strings.EqualFold(meta.Role, r.ID) {
+				ru.Interfaces = append(ru.Interfaces, ifaceName)
+			}
+		}
+		ru.AssignedCount = len(ru.Interfaces)
+		result[i] = ru
+	}
+	return result
+}
+
+// GetRoles returns all role tags.
+func (mm *MetadataManager) GetRoles() []RoleTag {
+	mm.mu.RLock()
+	defer mm.mu.RUnlock()
+	if len(mm.store.Roles) == 0 {
+		return defaultRoles
+	}
+	return append([]RoleTag(nil), mm.store.Roles...)
+}
+
+// GetRole returns a specific role tag by ID.
+func (mm *MetadataManager) GetRole(id string) (*RoleTag, error) {
+	mm.mu.RLock()
+	defer mm.mu.RUnlock()
+
+	for _, r := range mm.store.Roles {
+		if strings.EqualFold(r.ID, id) || strings.EqualFold(r.Name, id) {
+			rCopy := r
+			return &rCopy, nil
+		}
+	}
+	return nil, fmt.Errorf("role tag '%s' not found", id)
+}
+
+// SaveRole creates or updates a role tag.
+func (mm *MetadataManager) SaveRole(role RoleTag) (*RoleTag, error) {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+
+	name := strings.TrimSpace(role.Name)
+	if name == "" {
+		return nil, fmt.Errorf("role name cannot be empty")
+	}
+
+	color := strings.ToLower(strings.TrimSpace(role.Color))
+	if color == "" {
+		color = "blue"
+	}
+	role.Color = color
+	role.Name = name
+
+	id := strings.TrimSpace(role.ID)
+	if id == "" {
+		slug := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
+		id = slug
+		for _, existing := range mm.store.Roles {
+			if existing.ID == id {
+				id = fmt.Sprintf("%s-%d", slug, time.Now().Unix()%10000)
+				break
+			}
+		}
+	}
+	role.ID = id
+
+	found := false
+	for i, existing := range mm.store.Roles {
+		if strings.EqualFold(existing.ID, id) {
+			oldName := existing.Name
+			role.Builtin = existing.Builtin
+			mm.store.Roles[i] = role
+			found = true
+
+			// If name changed, migrate assigned interfaces
+			if oldName != "" && !strings.EqualFold(oldName, name) {
+				for ifName, meta := range mm.store.Interfaces {
+					if strings.EqualFold(meta.Role, oldName) {
+						meta.Role = name
+						mm.store.Interfaces[ifName] = meta
+					}
+				}
+			}
+			break
+		}
+	}
+
+	if !found {
+		mm.store.Roles = append(mm.store.Roles, role)
+	}
+
+	if err := mm.saveLocked(); err != nil {
+		return nil, err
+	}
+	return &role, nil
+}
+
+// DeleteRole removes a role tag.
+func (mm *MetadataManager) DeleteRole(id string) error {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+
+	idx := -1
+	var deletedRole RoleTag
+	for i, r := range mm.store.Roles {
+		if strings.EqualFold(r.ID, id) {
+			idx = i
+			deletedRole = r
+			break
+		}
+	}
+
+	if idx == -1 {
+		return fmt.Errorf("role tag '%s' not found", id)
+	}
+
+	mm.store.Roles = append(mm.store.Roles[:idx], mm.store.Roles[idx+1:]...)
+
+	// Reset any interfaces that used this role to "LAN" or empty
+	for ifName, meta := range mm.store.Interfaces {
+		if strings.EqualFold(meta.Role, deletedRole.Name) || strings.EqualFold(meta.Role, deletedRole.ID) {
+			meta.Role = "LAN"
+			mm.store.Interfaces[ifName] = meta
+		}
+	}
+
+	return mm.saveLocked()
+}
+
 func (mm *MetadataManager) Get(name string) InterfaceMetadata {
 	mm.mu.RLock()
 	defer mm.mu.RUnlock()

+ 17 - 0
internal/network/types.go

@@ -101,6 +101,22 @@ type AddIPRequest struct {
 	IP string `json:"ip"` // CIDR, e.g. "192.168.20.1/24"
 }
 
+// RoleTag defines an organizational label for classifying interfaces.
+type RoleTag struct {
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Color       string `json:"color"` // e.g. "amber", "blue", "cyan", "emerald", "purple", "rose", "slate", "indigo"
+	Description string `json:"description"`
+	Builtin     bool   `json:"builtin,omitempty"`
+}
+
+// RoleTagWithUsage includes assigned interface names and usage count.
+type RoleTagWithUsage struct {
+	RoleTag
+	AssignedCount int      `json:"assigned_count"`
+	Interfaces    []string `json:"interfaces"`
+}
+
 // InterfaceMetadata stores custom persistent labels and IP assignments per interface.
 type InterfaceMetadata struct {
 	Role        string   `json:"role"`
@@ -112,4 +128,5 @@ type InterfaceMetadata struct {
 // MetadataStore is written to disk to preserve user labels across restarts.
 type MetadataStore struct {
 	Interfaces map[string]InterfaceMetadata `json:"interfaces"`
+	Roles      []RoleTag                    `json:"roles,omitempty"`
 }

+ 276 - 8
internal/web/static/app.js

@@ -288,6 +288,11 @@ function navigate(tabName) {
     }
   });
 
+  if (tabName === 'interfaces') {
+    loadRoles();
+    if (state.activeIfaceSubTab === 'roles') switchIfaceSubTab('roles');
+    else switchIfaceSubTab('tree');
+  }
   if (tabName === 'bridge') loadBridge();
   if (tabName === 'firewall') loadFirewall();
   if (tabName === 'routes') loadRoutes();
@@ -325,7 +330,7 @@ async function fetchData(isManual = false) {
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
-    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes] = await Promise.all([
+    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes, rolesRes] = await Promise.all([
       fetch(`${API_BASE}/system/status`),
       fetch(`${API_BASE}/interfaces`),
       fetch(`${API_BASE}/vlans`),
@@ -335,9 +340,16 @@ async function fetchData(isManual = false) {
       fetch(`${API_BASE}/bridge/summary`),
       fetch(`${API_BASE}/dhcp/clients/summary`),
       fetch(`${API_BASE}/dhcp/clients`),
-      fetch(`${API_BASE}/vrrp/summary`).catch(() => null)
+      fetch(`${API_BASE}/vrrp/summary`).catch(() => null),
+      fetch(`${API_BASE}/roles`).catch(() => null)
     ]);
 
+    if (rolesRes && rolesRes.ok) {
+      state.roles = await rolesRes.json();
+      const countEl = document.getElementById('badge-roles-count');
+      if (countEl) countEl.innerText = (state.roles || []).length;
+    }
+
     if (vrrpSumRes && vrrpSumRes.ok) {
       state.vrrpSummary = await vrrpSumRes.json();
       renderVRRPSummary();
@@ -387,7 +399,10 @@ async function fetchData(isManual = false) {
     updateBadges();
 
     if (state.activeTab === 'dashboard') renderDashboard();
-    if (state.activeTab === 'interfaces') renderInterfacesList();
+    if (state.activeTab === 'interfaces') {
+      if (state.activeIfaceSubTab === 'roles') renderRolesTable();
+      else renderInterfacesList();
+    }
     if (state.activeTab === 'vlans') renderVlansList();
     if (state.activeTab === 'firewall') loadFirewall(false);
     if (state.activeTab === 'routes') loadRoutes(false);
@@ -694,7 +709,7 @@ function renderDashboard() {
           <span>${i.name}</span>
         </td>
         <td class="py-2.5 text-slate-300 font-sans">
-          <span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 font-medium">${i.role || i.type || 'Ethernet'}</span>
+          ${getRoleBadge(i.role || 'LAN')}
         </td>
         <td class="py-2.5 text-slate-400 text-[11px] font-mono">${i.mac || '-'}</td>
         <td class="py-2.5 text-slate-200 font-semibold">${i.ipv4 && i.ipv4.length > 0 ? i.ipv4.join(', ') : '<span class="text-slate-600 font-normal">Unassigned</span>'}</td>
@@ -783,6 +798,256 @@ function expandAllIfaces(expand) {
   renderInterfacesList();
 }
 
+// =========================================================================
+// INTERFACE ROLE TAGS MANAGEMENT
+// =========================================================================
+
+const ROLE_COLORS = {
+  amber:   { bg: 'bg-amber-500/20',   text: 'text-amber-300',   border: 'border-amber-500/30' },
+  blue:    { bg: 'bg-blue-500/20',    text: 'text-blue-300',    border: 'border-blue-500/30' },
+  cyan:    { bg: 'bg-cyan-500/20',    text: 'text-cyan-300',    border: 'border-cyan-500/30' },
+  emerald: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', border: 'border-emerald-500/30' },
+  purple:  { bg: 'bg-purple-500/20',  text: 'text-purple-300',  border: 'border-purple-500/30' },
+  rose:    { bg: 'bg-rose-500/20',    text: 'text-rose-300',    border: 'border-rose-500/30' },
+  indigo:  { bg: 'bg-indigo-500/20',  text: 'text-indigo-300',  border: 'border-indigo-500/30' },
+  sky:     { bg: 'bg-sky-500/20',     text: 'text-sky-300',     border: 'border-sky-500/30' },
+  slate:   { bg: 'bg-slate-800',      text: 'text-slate-300',   border: 'border-slate-700' }
+};
+
+function getRoleBadge(roleName) {
+  const name = roleName || 'LAN';
+  const role = (state.roles || []).find(r => r.name.toLowerCase() === name.toLowerCase() || r.id.toLowerCase() === name.toLowerCase());
+  const colorKey = (role?.color || 'blue').toLowerCase();
+  const theme = ROLE_COLORS[colorKey] || ROLE_COLORS.blue;
+  return `<span class="px-2 py-0.5 rounded text-[10px] font-semibold ${theme.bg} ${theme.text} border ${theme.border}" title="Role Tag: ${escapeHtml(name)}">${escapeHtml(name)}</span>`;
+}
+
+function switchIfaceSubTab(subTab) {
+  state.activeIfaceSubTab = subTab;
+  const treeTab = document.getElementById('ifaces-subtab-tree');
+  const rolesTab = document.getElementById('ifaces-subtab-roles');
+  const btnTree = document.getElementById('ifaces-subtab-btn-tree');
+  const btnRoles = document.getElementById('ifaces-subtab-btn-roles');
+
+  if (subTab === 'roles') {
+    treeTab?.classList.add('hidden');
+    rolesTab?.classList.remove('hidden');
+    if (btnRoles) {
+      btnRoles.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-brand-500/10 text-brand-400 border border-brand-500/20 transition flex items-center gap-1.5';
+    }
+    if (btnTree) {
+      btnTree.className = 'px-3.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-white hover:bg-slate-800 transition flex items-center gap-1.5';
+    }
+    loadRoles();
+  } else {
+    rolesTab?.classList.add('hidden');
+    treeTab?.classList.remove('hidden');
+    if (btnTree) {
+      btnTree.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-brand-500/10 text-brand-400 border border-brand-500/20 transition flex items-center gap-1.5';
+    }
+    if (btnRoles) {
+      btnRoles.className = 'px-3.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-white hover:bg-slate-800 transition flex items-center gap-1.5';
+    }
+    renderInterfacesList();
+  }
+}
+
+async function loadRoles() {
+  try {
+    const res = await fetch(`${API_BASE}/roles`);
+    if (res.ok) {
+      state.roles = await res.json();
+      const countEl = document.getElementById('badge-roles-count');
+      if (countEl) countEl.innerText = (state.roles || []).length;
+      renderRolesTable();
+      populateRoleSelects();
+    }
+  } catch (err) {
+    console.error('Failed to load role tags:', err);
+  }
+}
+
+function renderRolesTable() {
+  const tbody = document.getElementById('roles-table-body');
+  const empty = document.getElementById('roles-empty-state');
+  if (!tbody) return;
+
+  const roles = state.roles || [];
+  if (roles.length === 0) {
+    tbody.innerHTML = '';
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  tbody.innerHTML = roles.map(r => {
+    const colorKey = (r.color || 'blue').toLowerCase();
+    const theme = ROLE_COLORS[colorKey] || ROLE_COLORS.blue;
+    const ifaces = Array.isArray(r.interfaces) ? r.interfaces : [];
+    const ifacesChips = ifaces.length > 0
+      ? ifaces.map(name => `<span onclick="openIfaceModal('${escapeHtml(name)}')" class="cursor-pointer px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 text-slate-200 border border-slate-700 hover:border-cyan-500 transition mr-1 mb-1 inline-block" title="Click to view/edit ${escapeHtml(name)}">${escapeHtml(name)}</span>`).join('')
+      : '<span class="text-slate-600 font-sans text-xs italic">Unassigned (0)</span>';
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-3 px-4">
+          <span class="px-2.5 py-1 rounded text-xs font-semibold ${theme.bg} ${theme.text} border ${theme.border}">
+            ${escapeHtml(r.name)}
+          </span>
+        </td>
+        <td class="py-3 px-4 font-bold text-white font-mono flex items-center gap-2">
+          <span>${escapeHtml(r.name)}</span>
+          ${r.builtin ? '<span class="text-[9px] px-1.5 py-0.2 rounded bg-slate-800 text-slate-400 border border-slate-700 uppercase font-sans">Builtin</span>' : ''}
+        </td>
+        <td class="py-3 px-4 font-sans capitalize text-slate-300">
+          <span class="inline-flex items-center gap-1.5">
+            <span class="w-2 h-2 rounded-full ${theme.bg.replace('/20', '')}"></span>
+            <span>${escapeHtml(r.color || 'blue')}</span>
+          </span>
+        </td>
+        <td class="py-3 px-4 font-sans text-slate-400 text-xs">${escapeHtml(r.description || '-')}</td>
+        <td class="py-3 px-4 font-sans">
+          <div class="flex flex-wrap items-center">
+            ${ifacesChips}
+          </div>
+        </td>
+        <td class="py-3 px-4 text-right space-x-2 font-sans whitespace-nowrap">
+          <button onclick="openRoleModal('${escapeHtml(r.id)}')"
+                  class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold px-2 py-1 rounded hover:bg-slate-800 transition">
+            Edit
+          </button>
+          <button onclick="deleteRoleTag('${escapeHtml(r.id)}', '${escapeHtml(r.name)}')"
+                  class="text-xs text-rose-400 hover:text-rose-300 font-semibold px-2 py-1 rounded hover:bg-slate-800 transition">
+            Delete
+          </button>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function openRoleModal(id = null) {
+  const modal = document.getElementById('role-modal');
+  if (!modal) return;
+
+  const idInput = document.getElementById('role-modal-id');
+  const nameInput = document.getElementById('role-modal-name');
+  const colorSelect = document.getElementById('role-modal-color');
+  const descInput = document.getElementById('role-modal-desc');
+  const title = document.getElementById('role-modal-title');
+
+  if (id) {
+    const role = (state.roles || []).find(r => r.id === id);
+    if (role) {
+      if (title) title.innerText = `Edit Role Tag: ${role.name}`;
+      if (idInput) idInput.value = role.id;
+      if (nameInput) nameInput.value = role.name;
+      if (colorSelect) colorSelect.value = role.color || 'blue';
+      if (descInput) descInput.value = role.description || '';
+    }
+  } else {
+    if (title) title.innerText = 'Add Role Tag';
+    if (idInput) idInput.value = '';
+    if (nameInput) nameInput.value = '';
+    if (colorSelect) colorSelect.value = 'blue';
+    if (descInput) descInput.value = '';
+  }
+
+  updateRoleModalPreview();
+  modal.classList.remove('hidden');
+}
+
+function closeRoleModal() {
+  const modal = document.getElementById('role-modal');
+  if (modal) modal.classList.add('hidden');
+}
+
+function updateRoleModalPreview() {
+  const name = document.getElementById('role-modal-name')?.value.trim() || 'Preview';
+  const colorKey = (document.getElementById('role-modal-color')?.value || 'blue').toLowerCase();
+  const theme = ROLE_COLORS[colorKey] || ROLE_COLORS.blue;
+  const preview = document.getElementById('role-modal-preview');
+  if (preview) {
+    preview.innerText = name;
+    preview.className = `px-2.5 py-1 rounded text-xs font-semibold ${theme.bg} ${theme.text} border ${theme.border}`;
+  }
+}
+
+async function submitSaveRole(e) {
+  e.preventDefault();
+  const id = document.getElementById('role-modal-id')?.value;
+  const name = document.getElementById('role-modal-name')?.value.trim();
+  const color = document.getElementById('role-modal-color')?.value;
+  const desc = document.getElementById('role-modal-desc')?.value.trim();
+
+  if (!name) return;
+
+  try {
+    const payload = { id, name, color, description: desc };
+    const method = id ? 'PUT' : 'POST';
+    const url = id ? `${API_BASE}/roles/${encodeURIComponent(id)}` : `${API_BASE}/roles`;
+
+    const res = await fetch(url, {
+      method,
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+
+    if (res.ok) {
+      showToast('Role Tag Saved', `Role tag "${name}" saved successfully`, 'success');
+      closeRoleModal();
+      await loadRoles();
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to save role tag', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteRoleTag(id, name) {
+  if (!confirm(`Are you sure you want to delete role tag "${name}"?\nInterfaces assigned to this role will default to LAN.`)) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/roles/${encodeURIComponent(id)}`, {
+      method: 'DELETE'
+    });
+
+    if (res.ok) {
+      showToast('Role Tag Deleted', `Role tag "${name}" removed`, 'success');
+      await loadRoles();
+      fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to delete role tag', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function populateRoleSelects(selectedRole = '') {
+  const ifaceSelect = document.getElementById('modal-iface-role');
+  const vlanSelect = document.getElementById('vlan-role');
+
+  const roles = state.roles || [];
+  const optionsHtml = roles.map(r => `<option value="${escapeHtml(r.name)}">${escapeHtml(r.name)}${r.description ? ` (${escapeHtml(r.description)})` : ''}</option>`).join('');
+
+  if (ifaceSelect) {
+    const currentVal = selectedRole || ifaceSelect.value || 'LAN';
+    ifaceSelect.innerHTML = optionsHtml;
+    if (currentVal) ifaceSelect.value = currentVal;
+  }
+
+  if (vlanSelect) {
+    const currentVal = vlanSelect.value || 'LAN';
+    vlanSelect.innerHTML = optionsHtml;
+    if (currentVal) vlanSelect.value = currentVal;
+  }
+}
+
 // Filter and Render Hierarchical Interfaces List
 function filterIfaces(filter) {
   state.ifaceFilter = filter || 'all';
@@ -893,7 +1158,7 @@ function renderInterfacesList() {
       </td>
       <td class="py-3 px-4 font-sans">
         <div class="flex items-center gap-1.5">
-          <span class="px-2 py-0.5 rounded text-[10px] font-semibold bg-slate-800 text-slate-300 border border-slate-700/60">${parent.role || 'LAN'}</span>
+          ${getRoleBadge(parent.role || 'LAN')}
           <span class="text-xs text-slate-400 truncate max-w-[120px]">${parent.description || '-'}</span>
         </div>
       </td>
@@ -1024,8 +1289,10 @@ function renderInterfacesList() {
                           })()}
                         </td>
                         <td class="py-2.5 px-4 font-sans text-slate-300">
-                          <span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 mr-1.5">${v.role || 'VLAN'}</span>
-                          <span class="text-slate-400 text-xs">${v.description || '-'}</span>
+                          <div class="flex items-center gap-1.5">
+                            ${getRoleBadge(v.role || 'LAN')}
+                            <span class="text-slate-400 text-xs">${v.description || '-'}</span>
+                          </div>
                         </td>
                         <td class="py-2.5 px-4 text-slate-400">${v.mtu}</td>
                         <td class="py-2.5 px-4 text-[11px]">
@@ -2930,6 +3197,7 @@ function openVlanModal(defaultParent = '') {
   document.getElementById('vlan-name').value = '';
   document.getElementById('vlan-ip').value = '';
   document.getElementById('vlan-desc').value = '';
+  populateRoleSelects('LAN');
   autoSuggestVlanName();
   document.getElementById('vlan-modal').classList.remove('hidden');
 }
@@ -3026,7 +3294,7 @@ function openIfaceModal(name) {
   document.getElementById('modal-iface-name').innerText = iface.name;
   document.getElementById('modal-iface-badge').innerText = iface.is_vlan ? `VLAN ${iface.vlan_id}` : iface.type;
   document.getElementById('modal-iface-mtu').value = iface.mtu;
-  document.getElementById('modal-iface-role').value = iface.role || 'LAN';
+  populateRoleSelects(iface.role || 'LAN');
   document.getElementById('modal-iface-desc').value = iface.description || '';
 
   const nameInput = document.getElementById('modal-iface-new-name');

+ 156 - 49
internal/web/static/index.html

@@ -581,60 +581,119 @@
 
         <!-- ================= SECTION: INTERFACES (HIERARCHICAL TREE) ================= -->
         <section id="section-interfaces" class="hidden space-y-6">
-          <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center justify-between gap-4">
-            <div class="relative flex-1 min-w-[280px]">
-              <div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
-                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
-                </svg>
-              </div>
-              <input type="text" id="iface-search" oninput="renderInterfacesList()"
-                     placeholder="Search interfaces by Name or IP (e.g. eth0, 192.168.1.1)..."
-                     class="w-full bg-slate-800/90 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-500 transition">
-            </div>
-
+          <!-- Top Sub-Tab Navigation -->
+          <div class="flex items-center justify-between border-b border-slate-800 pb-3 flex-wrap gap-3">
             <div class="flex items-center space-x-2">
-              <button onclick="expandAllIfaces(true)" class="text-xs text-cyan-400 hover:text-cyan-300 font-medium px-3 py-2 rounded-lg bg-cyan-950/50 hover:bg-cyan-950 border border-cyan-800/60 transition flex items-center gap-1.5">
-                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
-                <span>Expand All</span>
+              <button id="ifaces-subtab-btn-tree" onclick="switchIfaceSubTab('tree')"
+                      class="px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-brand-500/10 text-brand-400 border border-brand-500/20 transition flex items-center gap-1.5">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
+                <span>Interfaces List</span>
               </button>
-              <button onclick="expandAllIfaces(false)" class="text-xs text-slate-400 hover:text-slate-300 font-medium px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition flex items-center gap-1.5">
-                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
-                <span>Collapse All</span>
+              <button id="ifaces-subtab-btn-roles" onclick="switchIfaceSubTab('roles')"
+                      class="px-3.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-white hover:bg-slate-800 transition flex items-center gap-1.5">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
+                <span>Role Tags Management</span>
+                <span id="badge-roles-count" class="px-1.5 py-0.2 rounded text-[10px] font-mono bg-slate-800 text-slate-300 border border-slate-700">0</span>
               </button>
             </div>
           </div>
 
-          <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
-            <div class="p-4 border-b border-slate-800 bg-slate-950/40 flex items-center justify-between">
-              <div>
-                <h3 class="text-sm font-bold text-white">Interface Table View</h3>
+          <!-- SUBTAB 1: Interfaces Tree View -->
+          <div id="ifaces-subtab-tree" class="space-y-6">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center justify-between gap-4">
+              <div class="relative flex-1 min-w-[280px]">
+                <div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
+                  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+                  </svg>
+                </div>
+                <input type="text" id="iface-search" oninput="renderInterfacesList()"
+                       placeholder="Search interfaces by Name or IP (e.g. eth0, 192.168.1.1)..."
+                       class="w-full bg-slate-800/90 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-500 transition">
               </div>
-              <div class="text-xs text-slate-400">
-                <span id="ifaces-list-summary" class="font-mono">Loading interfaces...</span>
+
+              <div class="flex items-center space-x-2">
+                <button onclick="expandAllIfaces(true)" class="text-xs text-cyan-400 hover:text-cyan-300 font-medium px-3 py-2 rounded-lg bg-cyan-950/50 hover:bg-cyan-950 border border-cyan-800/60 transition flex items-center gap-1.5">
+                  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
+                  <span>Expand All</span>
+                </button>
+                <button onclick="expandAllIfaces(false)" class="text-xs text-slate-400 hover:text-slate-300 font-medium px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition flex items-center gap-1.5">
+                  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
+                  <span>Collapse All</span>
+                </button>
               </div>
             </div>
 
-            <div class="overflow-x-auto">
-              <table class="w-full text-left text-xs">
-                <thead class="text-slate-400 bg-slate-950/80 border-b border-slate-800 uppercase tracking-wider text-[11px]">
-                  <tr>
-                    <th class="py-3 px-3 font-semibold w-8 text-center"></th>
-                    <th class="py-3 px-4 font-semibold">Interface / Type</th>
-                    <th class="py-3 px-4 font-semibold">Role & Alias</th>
-                    <th class="py-3 px-4 font-semibold">IP Address (CIDR)</th>
-                    <th class="py-3 px-4 font-semibold">MAC & MTU</th>
-                    <th class="py-3 px-4 font-semibold">Throughput (RX/TX)</th>
-                    <th class="py-3 px-4 font-semibold">Link State</th>
-                    <th class="py-3 px-4 font-semibold text-right">Actions</th>
-                  </tr>
-                </thead>
-                <tbody id="interfaces-tree-tbody" class="divide-y divide-slate-800 font-mono"></tbody>
-              </table>
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
+              <div class="p-4 border-b border-slate-800 bg-slate-950/40 flex items-center justify-between">
+                <div>
+                  <h3 class="text-sm font-bold text-white">Interface Table View</h3>
+                </div>
+                <div class="text-xs text-slate-400">
+                  <span id="ifaces-list-summary" class="font-mono">Loading interfaces...</span>
+                </div>
+              </div>
+
+              <div class="overflow-x-auto">
+                <table class="w-full text-left text-xs">
+                  <thead class="text-slate-400 bg-slate-950/80 border-b border-slate-800 uppercase tracking-wider text-[11px]">
+                    <tr>
+                      <th class="py-3 px-3 font-semibold w-8 text-center"></th>
+                      <th class="py-3 px-4 font-semibold">Interface / Type</th>
+                      <th class="py-3 px-4 font-semibold">Role & Alias</th>
+                      <th class="py-3 px-4 font-semibold">IP Address (CIDR)</th>
+                      <th class="py-3 px-4 font-semibold">MAC & MTU</th>
+                      <th class="py-3 px-4 font-semibold">Throughput (RX/TX)</th>
+                      <th class="py-3 px-4 font-semibold">Link State</th>
+                      <th class="py-3 px-4 font-semibold text-right">Actions</th>
+                    </tr>
+                  </thead>
+                  <tbody id="interfaces-tree-tbody" class="divide-y divide-slate-800 font-mono"></tbody>
+                </table>
+              </div>
+
+              <div id="interfaces-empty-state" class="hidden p-12 text-center text-slate-500">
+                No interfaces found matching your search.
+              </div>
             </div>
+          </div>
 
-            <div id="interfaces-empty-state" class="hidden p-12 text-center text-slate-500">
-              No interfaces found matching your search.
+          <!-- SUBTAB 2: Role Tags Management -->
+          <div id="ifaces-subtab-roles" class="hidden space-y-6">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
+              <div class="p-4 border-b border-slate-800 bg-slate-950/40 flex items-center justify-between flex-wrap gap-3">
+                <div>
+                  <h3 class="text-sm font-bold text-white flex items-center gap-2">
+                    <span>Interface Role Tags</span>
+                  </h3>
+                  <p class="text-xs text-slate-400 mt-0.5">Define custom organizational role tags with colors to classify, filter, and assign to network interfaces.</p>
+                </div>
+                <button onclick="openRoleModal()"
+                        class="px-3.5 py-2 rounded-lg font-semibold text-xs bg-brand-600 hover:bg-brand-500 text-white shadow-lg shadow-brand-500/20 transition flex items-center gap-1.5">
+                  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
+                  <span>+ Add Role Tag</span>
+                </button>
+              </div>
+
+              <div class="overflow-x-auto">
+                <table class="w-full text-left text-xs">
+                  <thead class="text-slate-400 bg-slate-950/80 border-b border-slate-800 uppercase tracking-wider text-[11px] font-sans">
+                    <tr>
+                      <th class="py-3 px-4 font-semibold w-28">Badge Preview</th>
+                      <th class="py-3 px-4 font-semibold">Role Name</th>
+                      <th class="py-3 px-4 font-semibold">Color Theme</th>
+                      <th class="py-3 px-4 font-semibold">Description</th>
+                      <th class="py-3 px-4 font-semibold">Assigned Interfaces</th>
+                      <th class="py-3 px-4 font-semibold text-right">Actions</th>
+                    </tr>
+                  </thead>
+                  <tbody id="roles-table-body" class="divide-y divide-slate-800 font-mono"></tbody>
+                </table>
+              </div>
+
+              <div id="roles-empty-state" class="hidden p-12 text-center text-slate-500 font-sans">
+                No role tags defined. Click "+ Add Role Tag" to create one.
+              </div>
             </div>
           </div>
         </section>
@@ -4469,14 +4528,11 @@
 
         <div class="grid grid-cols-2 gap-4">
           <div>
-            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Role Tag</label>
+            <div class="flex items-center justify-between mb-1.5">
+              <label class="block text-xs font-semibold text-slate-300">Role Tag</label>
+              <button type="button" onclick="closeIfaceModal(); switchIfaceSubTab('roles')" class="text-[10px] text-cyan-400 hover:underline">Manage Tags</button>
+            </div>
             <select id="modal-iface-role" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white">
-              <option value="WAN">WAN / Uplink</option>
-              <option value="LAN">LAN</option>
-              <option value="DMZ">DMZ</option>
-              <option value="GUEST">Guest</option>
-              <option value="IOT">IoT</option>
-              <option value="MGMT">Management</option>
             </select>
           </div>
           <div>
@@ -4496,6 +4552,57 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ROLE TAG ================= -->
+  <div id="role-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="role-modal-title" class="text-sm font-bold text-white">Add Role Tag</h3>
+        <button onclick="closeRoleModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveRole(event)" class="p-6 space-y-4 text-xs font-sans">
+        <input type="hidden" id="role-modal-id">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Role Tag Name *</label>
+          <input type="text" id="role-modal-name" required placeholder="e.g. VoIP, Cameras, Trunk, Backbone, WAN2"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500"
+                 oninput="updateRoleModalPreview()">
+        </div>
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Color Theme *</label>
+            <select id="role-modal-color" onchange="updateRoleModalPreview()"
+                    class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+              <option value="amber">Amber (Yellow/Orange)</option>
+              <option value="blue" selected>Blue</option>
+              <option value="cyan">Cyan (Sky Blue)</option>
+              <option value="emerald">Emerald (Green)</option>
+              <option value="purple">Purple</option>
+              <option value="rose">Rose (Red)</option>
+              <option value="indigo">Indigo</option>
+              <option value="sky">Sky</option>
+              <option value="slate">Slate (Gray)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Badge Preview</label>
+            <div class="h-9 flex items-center">
+              <span id="role-modal-preview" class="px-2.5 py-1 rounded text-xs font-semibold bg-blue-500/20 text-blue-300 border border-blue-500/30">Preview</span>
+            </div>
+          </div>
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Description (Optional)</label>
+          <input type="text" id="role-modal-desc" placeholder="e.g. Dedicated security & surveillance devices"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500">
+        </div>
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeRoleModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-brand-600 hover:bg-brand-500 text-white shadow-lg shadow-brand-500/20">Save Role Tag</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: PPPoE CLIENT ================= -->
   <div id="pppoe-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">