mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 18:24:32 +00:00
- Implement centralized `resolve_prole_home` utility for consistent environment-based `PROLE_HOME` resolution across modules - Replace hardcoded home paths with `resolve_prole_home` - Refactor PV management to support iSCSI mounts and node placement from Ansible manifests - Improve Kubernetes manifest handling to dynamically apply namespaces per document - Adjust `knoe-db` build context path and related tests - Add utilities for detecting and applying Ansible-defined node labels and PVs
795 lines
28 KiB
Python
795 lines
28 KiB
Python
"""Network scan screen.
|
|
|
|
This screen combines two discovery activities into a single operator workflow:
|
|
- Kerberos authority / KDC candidate detection (via `prole-net/prole-agent`)
|
|
- Ollama server discovery (via `etc/init_ollama.sh scan`)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import socket
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
from knoe import screen as ui
|
|
from knoe.core.env import (
|
|
DEFAULT_OLLAMA_PORT,
|
|
get_resource_path,
|
|
_format_ollama_host,
|
|
resolve_prole_home,
|
|
)
|
|
from knoe.core.ollama_scan import run_ollama_scan
|
|
from knoe.core.stream_exec import run_streaming_cmd
|
|
|
|
|
|
def _get_global_scan_frames() -> list:
|
|
"""Lazy accessor to avoid circular import with __init__.py."""
|
|
import knoe.ui.screens as _pkg
|
|
|
|
return _pkg.GLOBAL_SCAN_FRAMES
|
|
|
|
|
|
class NetworkScreenMixin:
|
|
"""Network scan screen."""
|
|
|
|
def _render_network_scan_page(self):
|
|
"""Render combined Network + Ollama scan page."""
|
|
# Letterhead at top right (matching welcome screen theme)
|
|
content_width = self.bg_canvas.winfo_width() or 975
|
|
right_margin = content_width - 48
|
|
|
|
ui.canvas_text(
|
|
self,
|
|
right_margin,
|
|
40,
|
|
"Prole",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 32, "bold"),
|
|
anchor="ne",
|
|
)
|
|
ui.canvas_text(
|
|
self,
|
|
right_margin,
|
|
85,
|
|
"Infrastructure Automated.",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 18),
|
|
anchor="ne",
|
|
)
|
|
|
|
self._render_title("Network Configuration + Ollama", y=150)
|
|
self._render_paragraph(
|
|
"Run a single combined scan to (1) identify Kerberos authority / KDC candidates "
|
|
"and (2) discover Ollama servers on the subnet.",
|
|
y=205,
|
|
wrap=820,
|
|
)
|
|
|
|
# Shared status
|
|
x = 48
|
|
y = 260
|
|
self._combined_status_var = tk.StringVar(value="Ready")
|
|
status_item = ui.canvas_text(
|
|
self,
|
|
x,
|
|
y,
|
|
"Status: Ready",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 10),
|
|
)
|
|
self._canvas_items.append(status_item)
|
|
|
|
def _update_status_text(*_args):
|
|
try:
|
|
self.bg_canvas.itemconfig(
|
|
status_item, text=f"Status: {self._combined_status_var.get()}"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
self._combined_status_var.trace_add("write", _update_status_text)
|
|
|
|
# Start Combined Scan Button
|
|
self.scan_btn = tk.Button(
|
|
self.bg_canvas,
|
|
text="Start Combined Scan",
|
|
command=self._run_network_scan,
|
|
bg="#F5F5DC",
|
|
fg="black",
|
|
activebackground="#E5E5D5",
|
|
highlightbackground="#F5F5DC",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 10),
|
|
padx=14,
|
|
pady=6,
|
|
)
|
|
btn_window = self.bg_canvas.create_window(x + 160, y - 8, window=self.scan_btn, anchor="nw")
|
|
self._canvas_items.append(btn_window)
|
|
self._overlay_widgets.append(self.scan_btn)
|
|
|
|
# Shared console — dark terminal with green text (Ollama-style)
|
|
console_y = y + 28
|
|
console_frame = tk.Frame(
|
|
self.bg_canvas,
|
|
bg="#1e1e1e",
|
|
highlightbackground="#444",
|
|
highlightthickness=1,
|
|
)
|
|
console_window = self.bg_canvas.create_window(
|
|
x,
|
|
console_y,
|
|
window=console_frame,
|
|
anchor="nw",
|
|
width=900,
|
|
height=130,
|
|
)
|
|
self._canvas_items.append(console_window)
|
|
self._overlay_widgets.append(console_frame)
|
|
|
|
console_text = tk.Text(
|
|
console_frame,
|
|
bg="#1e1e1e",
|
|
fg="#00ff00",
|
|
font=("Menlo", 9),
|
|
relief="flat",
|
|
state="disabled",
|
|
wrap="word",
|
|
borderwidth=0,
|
|
highlightthickness=0,
|
|
insertbackground="#00ff00",
|
|
)
|
|
console_scroll = ttk.Scrollbar(
|
|
console_frame,
|
|
orient="vertical",
|
|
command=console_text.yview,
|
|
)
|
|
console_text.configure(yscrollcommand=console_scroll.set)
|
|
console_scroll.pack(side="right", fill="y")
|
|
console_text.pack(side="left", fill="both", expand=True)
|
|
self._overlay_widgets.append(console_text)
|
|
self._overlay_widgets.append(console_scroll)
|
|
self._combined_console = console_text
|
|
|
|
# Result panels
|
|
panels_y = console_y + 145
|
|
|
|
# --- KDC candidates (single-select) ---
|
|
kdc_frame = tk.Frame(
|
|
self.bg_canvas,
|
|
bg="white",
|
|
highlightbackground="#E0E0E0",
|
|
highlightthickness=1,
|
|
)
|
|
kdc_window = self.bg_canvas.create_window(
|
|
x,
|
|
panels_y,
|
|
window=kdc_frame,
|
|
anchor="nw",
|
|
width=440,
|
|
height=255,
|
|
)
|
|
self._canvas_items.append(kdc_window)
|
|
self._overlay_widgets.append(kdc_frame)
|
|
|
|
kdc_title = ui.canvas_text(
|
|
self,
|
|
x,
|
|
panels_y - 18,
|
|
"Kerberos Authority / KDC Candidates",
|
|
fill="#1d1d1f",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
self._canvas_items.append(kdc_title)
|
|
|
|
kdc_cols = ("host", "basis")
|
|
kdc_tree = ttk.Treeview(kdc_frame, columns=kdc_cols, show="headings", height=7)
|
|
kdc_tree.heading("host", text="Host / IP")
|
|
kdc_tree.heading("basis", text="Basis")
|
|
kdc_tree.column("host", width=170, anchor="w")
|
|
kdc_tree.column("basis", width=240, anchor="w")
|
|
kdc_tree.pack(side="left", fill="both", expand=True)
|
|
kdc_scroll = ttk.Scrollbar(kdc_frame, orient="vertical", command=kdc_tree.yview)
|
|
kdc_tree.configure(yscrollcommand=kdc_scroll.set)
|
|
kdc_scroll.pack(side="right", fill="y")
|
|
self._overlay_widgets.append(kdc_tree)
|
|
self._overlay_widgets.append(kdc_scroll)
|
|
self._kdc_tree = kdc_tree
|
|
|
|
self._kdc_notice_item = ui.canvas_text(
|
|
self,
|
|
x,
|
|
panels_y + 262,
|
|
"",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 10),
|
|
)
|
|
self._canvas_items.append(self._kdc_notice_item)
|
|
|
|
use_kdc_btn = tk.Button(
|
|
self.bg_canvas,
|
|
text="Use Selected Authority",
|
|
command=self._apply_selected_kdc_candidate,
|
|
bg="#F5F5DC",
|
|
fg="black",
|
|
activebackground="#E5E5D5",
|
|
highlightbackground="#F5F5DC",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 10),
|
|
padx=10,
|
|
pady=5,
|
|
)
|
|
use_kdc_window = self.bg_canvas.create_window(
|
|
x,
|
|
panels_y + 290,
|
|
window=use_kdc_btn,
|
|
anchor="nw",
|
|
)
|
|
self._canvas_items.append(use_kdc_window)
|
|
self._overlay_widgets.append(use_kdc_btn)
|
|
|
|
# --- Ollama servers (multi-select) ---
|
|
ox = x + 460
|
|
ollama_frame = tk.Frame(
|
|
self.bg_canvas,
|
|
bg="white",
|
|
highlightbackground="#E0E0E0",
|
|
highlightthickness=1,
|
|
)
|
|
ollama_window = self.bg_canvas.create_window(
|
|
ox,
|
|
panels_y,
|
|
window=ollama_frame,
|
|
anchor="nw",
|
|
width=440,
|
|
height=255,
|
|
)
|
|
self._canvas_items.append(ollama_window)
|
|
self._overlay_widgets.append(ollama_frame)
|
|
|
|
ollama_title = ui.canvas_text(
|
|
self,
|
|
ox,
|
|
panels_y - 18,
|
|
"Ollama Servers (select zero or many)",
|
|
fill="#1d1d1f",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
self._canvas_items.append(ollama_title)
|
|
|
|
o_cols = ("select", "host", "port", "models")
|
|
o_tree = ttk.Treeview(ollama_frame, columns=o_cols, show="headings", height=7)
|
|
o_tree.heading("select", text="Select")
|
|
o_tree.heading("host", text="Host")
|
|
o_tree.heading("port", text="Port")
|
|
o_tree.heading("models", text="Models")
|
|
o_tree.column("select", width=70, anchor="center")
|
|
o_tree.column("host", width=150, anchor="w")
|
|
o_tree.column("port", width=55, anchor="center")
|
|
o_tree.column("models", width=150, anchor="w")
|
|
o_tree.pack(side="left", fill="both", expand=True)
|
|
o_scroll = ttk.Scrollbar(ollama_frame, orient="vertical", command=o_tree.yview)
|
|
o_tree.configure(yscrollcommand=o_scroll.set)
|
|
o_scroll.pack(side="right", fill="y")
|
|
self._overlay_widgets.append(o_tree)
|
|
self._overlay_widgets.append(o_scroll)
|
|
self._ollama_tree = o_tree
|
|
|
|
self._ollama_notice_item = ui.canvas_text(
|
|
self,
|
|
ox,
|
|
panels_y + 262,
|
|
"",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 10),
|
|
)
|
|
self._canvas_items.append(self._ollama_notice_item)
|
|
|
|
# Toggle selection by clicking the Select column
|
|
def _on_ollama_click(event):
|
|
try:
|
|
item_id = o_tree.identify_row(event.y)
|
|
col = o_tree.identify_column(event.x)
|
|
if not item_id:
|
|
return
|
|
if col != "#1":
|
|
return
|
|
self._toggle_ollama_item(item_id)
|
|
except Exception:
|
|
pass
|
|
|
|
o_tree.bind("<Button-1>", _on_ollama_click)
|
|
|
|
# Load any existing selections into internal state
|
|
self._ollama_selected_keys = set()
|
|
self._ollama_primary_key = ""
|
|
self._load_ollama_selection_from_config()
|
|
|
|
# Seed KDC candidates from ansible topology / existing config
|
|
self._kdc_candidates: dict[str, str] = {}
|
|
self._seed_kdc_candidates_from_existing_state()
|
|
self._render_kdc_candidates()
|
|
|
|
# Seed console with topology summary (if present)
|
|
if getattr(self, "ansible_topology_summary", ""):
|
|
self._combined_console_write(
|
|
"[network] " + self.ansible_topology_summary.replace("\n", "\n[network] ")
|
|
)
|
|
self._combined_console_write(
|
|
"[network] Ansible topology loaded; combined scan can refine detection.\n"
|
|
)
|
|
|
|
# ---- Console helpers -------------------------------------------------
|
|
def _combined_console_write(self, content: str):
|
|
def _do_write():
|
|
console = getattr(self, "_combined_console", None)
|
|
if not console:
|
|
return
|
|
try:
|
|
console.configure(state="normal")
|
|
console.insert("end", content)
|
|
console.see("end")
|
|
console.configure(state="disabled")
|
|
except Exception:
|
|
pass
|
|
|
|
self.safe_after(_do_write)
|
|
|
|
def _combined_console_line(self, source: str, line: str):
|
|
src = (source or "").strip().lower() or "scan"
|
|
msg = (line or "").rstrip("\n")
|
|
self._combined_console_write(f"[{src}] {msg}\n")
|
|
|
|
def _run_network_scan(self):
|
|
if getattr(self, "_scan_running", False):
|
|
return
|
|
self._action_flags["network_scan.run"] = True
|
|
self._scan_running = True
|
|
self._combined_status_var.set("Scanning...")
|
|
|
|
# Disable button while running
|
|
try:
|
|
self.scan_btn.configure(state="disabled")
|
|
except Exception:
|
|
pass
|
|
|
|
# Clear console
|
|
console = getattr(self, "_combined_console", None)
|
|
if console:
|
|
try:
|
|
console.configure(state="normal")
|
|
console.delete("1.0", "end")
|
|
console.configure(state="disabled")
|
|
except Exception:
|
|
pass
|
|
|
|
self._combined_console_line("network", "Initializing prole-net/prole-agent ...")
|
|
self._combined_console_line("ollama", "Initializing subnet scan ...")
|
|
|
|
# Track completion of both scan activities
|
|
lock = threading.Lock()
|
|
remaining = {"network": True, "ollama": True}
|
|
outcomes: dict[str, tuple[bool, str]] = {}
|
|
|
|
def _mark_done(kind: str, ok: bool, msg: str):
|
|
nonlocal remaining
|
|
with lock:
|
|
remaining.pop(kind, None)
|
|
outcomes[kind] = (ok, msg)
|
|
if remaining:
|
|
return
|
|
|
|
def _finish():
|
|
self._scan_running = False
|
|
try:
|
|
self.scan_btn.configure(state="normal")
|
|
except Exception:
|
|
pass
|
|
net_ok, _ = outcomes.get("network", (False, ""))
|
|
ol_ok, _ = outcomes.get("ollama", (False, ""))
|
|
if net_ok and ol_ok:
|
|
self._combined_status_var.set("Scan complete")
|
|
elif net_ok or ol_ok:
|
|
self._combined_status_var.set("Scan complete (partial)")
|
|
else:
|
|
self._combined_status_var.set("Scan failed")
|
|
|
|
self.safe_after(_finish)
|
|
|
|
# --- Thread 1: prole-agent scan (network) ---
|
|
def network_worker():
|
|
ansible_kdc = ""
|
|
try:
|
|
ansible_kdc = (self.ansible_topology or {}).get("kdc_ip") or ""
|
|
if not ansible_kdc:
|
|
ansible_kdc = (self.prole_cfg_data.get("Network", {}) or {}).get(
|
|
"KDC_ANSIBLE_DETECTED", ""
|
|
)
|
|
except Exception:
|
|
ansible_kdc = ""
|
|
|
|
try:
|
|
scan_binary = get_resource_path("prole-net/prole-agent")
|
|
if not scan_binary.exists():
|
|
self._combined_console_line(
|
|
"network", f"Scan binary not found at {scan_binary}"
|
|
)
|
|
_mark_done("network", False, "missing binary")
|
|
return
|
|
|
|
prole_home = resolve_prole_home(env={})
|
|
scan_dir = prole_home / "scan"
|
|
scan_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
env = os.environ.copy()
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
|
|
line_buffer = ""
|
|
last_ui_update = 0.0
|
|
pending_lines: list[str] = []
|
|
|
|
kdc_re = re.compile(
|
|
r"(?:KDC\s*(?:is|IP)?\s*[:=]|kerberos_kdc\s*=)\s*(?P<host>[^\s\]]+)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
def _maybe_add_kdc_candidate(host: str, basis: str):
|
|
host = (host or "").strip().strip("[]():,")
|
|
if not host:
|
|
return
|
|
if host in self._kdc_candidates:
|
|
return
|
|
self._kdc_candidates[host] = basis
|
|
self.safe_after(self._render_kdc_candidates)
|
|
|
|
def _maybe_set_kdc_primary(host: str):
|
|
# Preserve current behavior: only auto-fill if ansible did not already set.
|
|
if not host or ansible_kdc:
|
|
return
|
|
current = (self.kerberos_kdc.get() or "").strip()
|
|
if current:
|
|
return
|
|
self.safe_after(lambda h=host: self.kerberos_kdc.set(h))
|
|
self.safe_after(lambda: self.kerberos_enabled.set(True))
|
|
try:
|
|
self.safe_after(self._save_prole_cfg)
|
|
except Exception:
|
|
pass
|
|
|
|
def _flush_pending(force: bool = False):
|
|
nonlocal pending_lines, last_ui_update
|
|
if not pending_lines:
|
|
return
|
|
now = time.time()
|
|
if not force:
|
|
if now - last_ui_update <= 0.05 and sum(
|
|
len(x) for x in pending_lines
|
|
) <= 2048:
|
|
return
|
|
chunk = "".join(pending_lines)
|
|
pending_lines = []
|
|
for ln in chunk.splitlines():
|
|
if ln:
|
|
self._combined_console_line("network", ln)
|
|
last_ui_update = now
|
|
|
|
def _parse_kdc_lines(text: str):
|
|
nonlocal line_buffer
|
|
line_buffer += text
|
|
while "\n" in line_buffer:
|
|
line, line_buffer = line_buffer.split("\n", 1)
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
m = kdc_re.search(line)
|
|
if m:
|
|
host = (m.group("host") or "").strip()
|
|
_maybe_add_kdc_candidate(host, "prole-agent")
|
|
_maybe_set_kdc_primary(host)
|
|
continue
|
|
# Fallback heuristic: AD / port 88 lines containing IPs
|
|
if "active directory" in line.lower() or " 88" in line or ":88" in line:
|
|
for part in line.split():
|
|
cand = part.strip("[]():,")
|
|
try:
|
|
socket.inet_aton(cand)
|
|
_maybe_add_kdc_candidate(cand, "heuristic")
|
|
_maybe_set_kdc_primary(cand)
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
def _on_chunk(text: str):
|
|
pending_lines.append(text)
|
|
_flush_pending()
|
|
_parse_kdc_lines(text)
|
|
|
|
rc = run_streaming_cmd(
|
|
[str(scan_binary)],
|
|
env=env,
|
|
cwd=str(scan_dir),
|
|
on_stdout=_on_chunk,
|
|
on_stderr=_on_chunk,
|
|
)
|
|
_flush_pending(force=True)
|
|
if line_buffer:
|
|
_parse_kdc_lines("\n")
|
|
|
|
_mark_done("network", rc == 0, f"rc={rc}")
|
|
except Exception as e:
|
|
self._combined_console_line("network", f"Scan error: {e}")
|
|
_mark_done("network", False, "exception")
|
|
|
|
# --- Thread 2: Ollama subnet scan ---
|
|
def ollama_worker():
|
|
try:
|
|
rc, rows, notice = run_ollama_scan(
|
|
Path(getattr(self, "project_root", Path.cwd())),
|
|
on_progress_line=lambda line: self._combined_console_line(
|
|
"ollama", line
|
|
),
|
|
)
|
|
|
|
def _update():
|
|
self._ollama_rows = rows
|
|
self._render_ollama_rows(rows)
|
|
try:
|
|
self.bg_canvas.itemconfig(
|
|
self._ollama_notice_item, text=notice or ""
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
self.safe_after(_update)
|
|
_mark_done("ollama", rc == 0, f"rc={rc}")
|
|
except Exception as e:
|
|
self._combined_console_line("ollama", f"Scan error: {e}")
|
|
_mark_done("ollama", False, "exception")
|
|
|
|
threading.Thread(target=network_worker, daemon=True).start()
|
|
threading.Thread(target=ollama_worker, daemon=True).start()
|
|
|
|
# ---- KDC candidate UI + persistence ---------------------------------
|
|
def _seed_kdc_candidates_from_existing_state(self):
|
|
# Existing config / ansible topology hints
|
|
try:
|
|
ansible_kdc = (self.ansible_topology or {}).get("kdc_ip") or ""
|
|
if not ansible_kdc:
|
|
ansible_kdc = (self.prole_cfg_data.get("Network", {}) or {}).get(
|
|
"KDC_ANSIBLE_DETECTED", ""
|
|
)
|
|
if ansible_kdc:
|
|
self._kdc_candidates[ansible_kdc] = "ansible topology"
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
current = (self.kerberos_kdc.get() or "").strip()
|
|
if current:
|
|
self._kdc_candidates.setdefault(current, "current selection")
|
|
except Exception:
|
|
pass
|
|
|
|
def _render_kdc_candidates(self):
|
|
tree = getattr(self, "_kdc_tree", None)
|
|
if not tree:
|
|
return
|
|
try:
|
|
for item in tree.get_children():
|
|
tree.delete(item)
|
|
except Exception:
|
|
pass
|
|
|
|
# Stable ordering: basis priority then host
|
|
items = sorted(self._kdc_candidates.items(), key=lambda kv: (kv[1] or "", kv[0]))
|
|
for host, basis in items:
|
|
try:
|
|
tree.insert("", "end", values=(host, basis))
|
|
except Exception:
|
|
pass
|
|
|
|
# Preselect when unambiguous
|
|
try:
|
|
current = (self.kerberos_kdc.get() or "").strip()
|
|
if current:
|
|
for item in tree.get_children():
|
|
v = tree.item(item, "values")
|
|
if v and v[0] == current:
|
|
tree.selection_set(item)
|
|
tree.see(item)
|
|
break
|
|
elif len(items) == 1:
|
|
only = tree.get_children()[0]
|
|
tree.selection_set(only)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
self.bg_canvas.itemconfig(
|
|
self._kdc_notice_item,
|
|
text=(
|
|
f"Selected: {(self.kerberos_kdc.get() or '').strip()}"
|
|
if (self.kerberos_kdc.get() or '').strip()
|
|
else "Selected: (none)"
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _apply_selected_kdc_candidate(self):
|
|
tree = getattr(self, "_kdc_tree", None)
|
|
if not tree:
|
|
return
|
|
sel = tree.selection()
|
|
if not sel:
|
|
return
|
|
vals = tree.item(sel[0], "values")
|
|
if not vals:
|
|
return
|
|
host = (vals[0] or "").strip()
|
|
if not host:
|
|
return
|
|
try:
|
|
self.kerberos_kdc.set(host)
|
|
self.kerberos_enabled.set(True)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._save_prole_cfg()
|
|
except Exception:
|
|
pass
|
|
self._render_kdc_candidates()
|
|
|
|
# ---- Ollama multi-select UI + persistence ----------------------------
|
|
def _ollama_key(self, host: str, port: str) -> str:
|
|
h = (host or "").strip()
|
|
p = (port or "").strip() or DEFAULT_OLLAMA_PORT
|
|
return f"{h}:{p}" if h else ""
|
|
|
|
def _parse_ollama_key(self, key: str) -> tuple[str, str]:
|
|
k = (key or "").strip()
|
|
if ":" not in k:
|
|
return k, DEFAULT_OLLAMA_PORT
|
|
host, port = k.rsplit(":", 1)
|
|
return host.strip(), (port.strip() or DEFAULT_OLLAMA_PORT)
|
|
|
|
def _load_ollama_selection_from_config(self):
|
|
data = self.prole_cfg_data.get("Ollama", {}) or {}
|
|
csv_val = (data.get("OLLAMA_SERVERS", "") or "").strip()
|
|
selected: set[str] = set()
|
|
if csv_val:
|
|
for part in csv_val.split(","):
|
|
part = part.strip()
|
|
if part:
|
|
selected.add(part)
|
|
|
|
# Back-compat: if no list key exists, fall back to legacy single-host settings.
|
|
if not selected:
|
|
host = (data.get("OLLAMA_SERVER_HOST") or "").strip() or (
|
|
getattr(self, "ollama_server_host", tk.StringVar(value="")).get() or ""
|
|
).strip()
|
|
port = (data.get("OLLAMA_SERVER_PORT") or "").strip() or (
|
|
getattr(self, "ollama_server_port", tk.StringVar(value="")).get() or ""
|
|
).strip()
|
|
if host:
|
|
selected.add(self._ollama_key(host, port))
|
|
|
|
self._ollama_selected_keys = selected
|
|
|
|
# Primary is whatever the legacy host/port currently indicates (if selected); otherwise first selected.
|
|
primary = self._ollama_key(
|
|
(getattr(self, "ollama_server_host", tk.StringVar(value="")).get() or "").strip(),
|
|
(getattr(self, "ollama_server_port", tk.StringVar(value="")).get() or "").strip(),
|
|
)
|
|
if primary and primary in selected:
|
|
self._ollama_primary_key = primary
|
|
else:
|
|
self._ollama_primary_key = next(iter(sorted(selected)), "")
|
|
|
|
def _render_ollama_rows(self, rows: list[dict]):
|
|
tree = getattr(self, "_ollama_tree", None)
|
|
if not tree:
|
|
return
|
|
try:
|
|
for item in tree.get_children():
|
|
tree.delete(item)
|
|
except Exception:
|
|
pass
|
|
|
|
for r in rows or []:
|
|
host = (r.get("host", "") or "").strip()
|
|
port = str((r.get("port", "") or "")).strip() or DEFAULT_OLLAMA_PORT
|
|
key = self._ollama_key(host, port)
|
|
models = r.get("models", []) or []
|
|
models_text = ", ".join(models) if isinstance(models, list) else str(models)
|
|
prefix = " [✓] " if key in self._ollama_selected_keys else " [ ] "
|
|
try:
|
|
tree.insert("", "end", values=(prefix, host, port, models_text))
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
n = len(self._ollama_selected_keys)
|
|
self.bg_canvas.itemconfig(
|
|
self._ollama_notice_item,
|
|
text=f"Selected: {n} server(s)" + (
|
|
f" (primary: {self._ollama_primary_key})" if self._ollama_primary_key else ""
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _toggle_ollama_item(self, item_id: str):
|
|
tree = getattr(self, "_ollama_tree", None)
|
|
if not tree:
|
|
return
|
|
vals = tree.item(item_id, "values")
|
|
if not vals or len(vals) < 4:
|
|
return
|
|
host = (vals[1] or "").strip()
|
|
port = (vals[2] or "").strip() or DEFAULT_OLLAMA_PORT
|
|
key = self._ollama_key(host, port)
|
|
if not key:
|
|
return
|
|
if key in self._ollama_selected_keys:
|
|
self._ollama_selected_keys.remove(key)
|
|
else:
|
|
self._ollama_selected_keys.add(key)
|
|
|
|
# Keep primary stable when possible
|
|
if self._ollama_primary_key and self._ollama_primary_key not in self._ollama_selected_keys:
|
|
self._ollama_primary_key = next(iter(sorted(self._ollama_selected_keys)), "")
|
|
if not self._ollama_primary_key and self._ollama_selected_keys:
|
|
self._ollama_primary_key = next(iter(sorted(self._ollama_selected_keys)), "")
|
|
|
|
# Update row prefix and persist
|
|
prefix = " [✓] " if key in self._ollama_selected_keys else " [ ] "
|
|
try:
|
|
new_vals = list(vals)
|
|
new_vals[0] = prefix
|
|
tree.item(item_id, values=new_vals)
|
|
except Exception:
|
|
pass
|
|
self._persist_ollama_multi_selection()
|
|
self._render_ollama_rows(getattr(self, "_ollama_rows", []) or [])
|
|
|
|
def _persist_ollama_multi_selection(self):
|
|
data = self.prole_cfg_data.setdefault("Ollama", {})
|
|
selected = sorted(self._ollama_selected_keys)
|
|
data["OLLAMA_SERVERS"] = ",".join(selected)
|
|
|
|
# Derive legacy single-host keys from primary (if any)
|
|
primary = self._ollama_primary_key
|
|
if primary:
|
|
host, port = self._parse_ollama_key(primary)
|
|
if host:
|
|
data["OLLAMA_SERVER_HOST"] = host
|
|
data["OLLAMA_SERVER_PORT"] = port
|
|
data["OLLAMA_HOST"] = _format_ollama_host(host, port)
|
|
try:
|
|
self.ollama_server_host.set(host)
|
|
self.ollama_server_port.set(port)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Selecting zero servers is valid.
|
|
data.pop("OLLAMA_SERVER_HOST", None)
|
|
data.pop("OLLAMA_SERVER_PORT", None)
|
|
data.pop("OLLAMA_HOST", None)
|
|
try:
|
|
self.ollama_server_host.set("")
|
|
self.ollama_server_port.set("")
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
self._save_prole_cfg()
|
|
except Exception:
|
|
pass
|