prole/installer/ui/screens/ollama.py
chrisfu ec50f82cc8 refactor: deduplicate installer business logic & split screens.py into package
Separation of concerns: merge silent/UI actions & modularize screens.

ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers.

screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility.

All 37 tests pass with no regressions.
2026-02-20 14:43:20 -08:00

568 lines
26 KiB
Python

"""Ollama LLM configuration screen."""
import configparser
import os
import re
import socket
import threading
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from installer import screen as ui
from installer.core.env import (
DEFAULT_OLLAMA_PORT,
PROJECT_ROOT,
_format_ollama_host,
_parse_ollama_host,
)
from installer.config import _collect_cfg_vars, _expand_cfg_value
class OllamaScreenMixin:
"""Ollama LLM configuration screen."""
def _render_ollama_config_page(self):
# 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('Ollama Server', y=150)
self._render_paragraph(
"Detect and select a network Ollama server. The selected host, port, and model are saved to prole.cfg "
"and exported as OLLAMA_* for k3s hosts and services.",
y=200
)
x_label = 48
x_field = 300
y = 270
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Host:', fill='black', font=('SF Pro Text', 12, 'bold')))
host_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_host, bg='white', fg='black',
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
relief='flat', font=('SF Pro Text', 11))
host_window = self.bg_canvas.create_window(x_field, y-12, window=host_entry, anchor='nw', width=400, height=32)
self._canvas_items.append(host_window)
self._overlay_widgets.append(host_entry)
y += 42
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Port:', fill='black', font=('SF Pro Text', 12, 'bold')))
port_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_port, bg='white', fg='black',
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
relief='flat', font=('SF Pro Text', 11))
port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=120, height=32)
self._canvas_items.append(port_window)
self._overlay_widgets.append(port_entry)
y += 42
# Removed global model selector; model is now selected per-server row
save_btn = tk.Button(self.bg_canvas, text='Save', command=self._save_ollama_config,
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 10), padx=16, pady=6)
save_window = self.bg_canvas.create_window(x_field + 320, y-14, window=save_btn, anchor='nw')
self._canvas_items.append(save_window)
self._overlay_widgets.append(save_btn)
y += 42
preview_item = ui.canvas_text(self, x_label, y, 'OLLAMA_HOST: ', fill='#6e6e73', font=('SF Pro Text', 10))
self._canvas_items.append(preview_item)
def update_preview(*_args):
host = (self.ollama_server_host.get() or '').strip()
port = (self.ollama_server_port.get() or '').strip()
url = _format_ollama_host(host, port)
text = f"OLLAMA_HOST: {url}" if url else "OLLAMA_HOST: (not set)"
try:
self.bg_canvas.itemconfig(preview_item, text=text)
except Exception:
pass
# No global model selector updates to avoid UI spin-lock
self.ollama_server_host.trace_add('write', update_preview)
self.ollama_server_port.trace_add('write', update_preview)
update_preview()
y += 40
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')))
refresh_btn = tk.Button(self.bg_canvas, text='Scan', command=self._refresh_ollama_table,
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 10), padx=10, pady=4)
refresh_window = self.bg_canvas.create_window(x_label + 190, y - 8, window=refresh_btn, anchor='nw')
self._canvas_items.append(refresh_window)
self._overlay_widgets.append(refresh_btn)
self._ollama_status_var = tk.StringVar(value="Ready")
status_item = ui.canvas_text(self, x_label + 270, 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._ollama_status_var.get()}")
except Exception:
pass
self._ollama_status_var.trace_add('write', update_status_text)
table_y = y + 30
table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1)
table_window = self.bg_canvas.create_window(x_label, table_y, window=table_frame, anchor='nw', width=900, height=220)
self._canvas_items.append(table_window)
self._overlay_widgets.append(table_frame)
# Add an editable Default Model column; available models shown for reference
columns = ('select', 'host', 'port', 'default', 'models')
tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=6)
tree.heading('select', text='Select')
tree.heading('host', text='Host')
tree.heading('port', text='Port')
tree.heading('default', text='Default Model')
tree.heading('models', text='Models')
tree.column('select', width=60, anchor='center')
tree.column('host', width=200, anchor='w')
tree.column('port', width=70, anchor='center')
tree.column('default', width=180, anchor='w')
tree.column('models', width=380, anchor='w')
tree.pack(side='left', fill='both', expand=True)
scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview)
tree.configure(yscrollcommand=scroll.set)
scroll.pack(side='right', fill='y')
self._overlay_widgets.append(tree)
self._overlay_widgets.append(scroll)
self._ollama_server_tree = tree
# Inline combobox to edit Default Model for the selected row
self._ollama_row_model_combo = None
def _place_row_model_combo(item_id):
try:
# Compute bbox for 'default' column
bbox = tree.bbox(item_id, column='default')
if not bbox:
return
x, y, w, h = bbox
# Create lazily
if not self._ollama_row_model_combo:
self._ollama_row_model_combo = ttk.Combobox(tree, state='readonly')
self._ollama_row_model_combo.bind('<<ComboboxSelected>>', _on_row_model_change)
# Identify models by key
vals = tree.item(item_id, 'values')
if not vals:
return
host = vals[1]
port = vals[2] or DEFAULT_OLLAMA_PORT
key = f"{host}:{port}"
models = self._ollama_model_options.get(key, [])
try:
self._ollama_row_model_combo.configure(values=models)
except Exception:
pass
# Set current selection
current = (self._ollama_default_model_by_key.get(key) or (models[0] if models else ''))
if current:
self._ollama_row_model_combo.set(current)
# Place within Treeview
self._ollama_row_model_combo.place(x=x+1, y=y+1, width=max(w-2, 60), height=h-2)
except Exception:
pass
def _on_row_model_change(_evt=None):
try:
if not self._ollama_row_model_combo:
return
sel = tree.selection()
if not sel:
return
item_id = sel[0]
vals = tree.item(item_id, 'values')
if not vals:
return
host = vals[1]
port = vals[2] or DEFAULT_OLLAMA_PORT
key = f"{host}:{port}"
choice = self._ollama_row_model_combo.get().strip()
self._ollama_default_model_by_key[key] = choice
# If this row is the active selection for host/port, reflect into self.ollama_model
current_key = self._ollama_current_key()
if current_key == key and choice:
try:
self.ollama_model.set(choice)
except Exception:
pass
# Update the table cell text
new_vals = list(vals)
# Default column index is 3
if len(new_vals) >= 4:
new_vals[3] = choice or '—'
tree.item(item_id, values=new_vals)
except Exception:
pass
def on_select(event):
if getattr(self, '_refreshing_ollama_table', False):
return
sel = tree.selection()
if not sel:
# Hide editor when nothing selected
if self._ollama_row_model_combo:
self._ollama_row_model_combo.place_forget()
return
vals = tree.item(sel[0], 'values')
if not vals:
return
host = vals[1]
port = vals[2] or DEFAULT_OLLAMA_PORT
self.ollama_server_host.set(host)
self.ollama_server_port.set(port)
key = f"{host}:{port}"
# If we have a stored default model for this key, reflect into self.ollama_model for saving
chosen = (self._ollama_default_model_by_key.get(key) or '')
if chosen:
try:
self.ollama_model.set(chosen)
except Exception:
pass
# Refresh to update the selection indicator, then place editor
self._render_ollama_table_from_cache()
# Reselect and place inline model editor over this row
try:
for item in tree.get_children():
v = tree.item(item, 'values')
if v and v[1] == host and (v[2] or DEFAULT_OLLAMA_PORT) == port:
tree.selection_set(item)
_place_row_model_combo(item)
break
except Exception:
pass
tree.bind('<<TreeviewSelect>>', on_select)
# Also allow double-click on default cell to focus the combobox
def on_double_click(event):
if getattr(self, '_refreshing_ollama_table', False):
return
item_id = tree.identify_row(event.y)
col = tree.identify_column(event.x)
if not item_id:
return
if col in ('#4', 'default'):
_place_row_model_combo(item_id)
tree.bind('<Double-1>', on_double_click)
self._ollama_note_item = ui.canvas_text(self, x_label, table_y + 230, '', fill='#6e6e73', font=('SF Pro Text', 10))
self._canvas_items.append(self._ollama_note_item)
self._render_ollama_table_from_cache()
self._refresh_ollama_table()
def _ollama_current_key(self) -> str:
host = (self.ollama_server_host.get() or '').strip()
port = (self.ollama_server_port.get() or '').strip() or DEFAULT_OLLAMA_PORT
if not host:
return ''
return f"{host}:{port}"
def _set_ollama_model_values(self, models: list[str]):
combo = getattr(self, '_ollama_model_combo', None)
if not combo:
return
if getattr(self, '_refreshing_ollama_table', False):
return
values = models or []
try:
combo.configure(values=values)
except Exception:
pass
current = (self.ollama_model.get() or '').strip()
if values and (not current or current not in values):
self.ollama_model.set(values[0])
def _render_ollama_table_from_cache(self):
rows = list(getattr(self, '_ollama_servers_cache', []) or [])
notice = getattr(self, '_ollama_last_notice', '')
self._update_ollama_table(rows, notice)
def _update_ollama_table(self, rows: list[dict], notice: str = ''):
tree = getattr(self, '_ollama_server_tree', None)
if not tree:
return
self._ollama_servers_cache = rows
# Map of key -> list[str] available models
self._ollama_model_options = {}
# Map of key -> selected default model
if not hasattr(self, '_ollama_default_model_by_key'):
self._ollama_default_model_by_key = {}
self._ollama_last_notice = notice or ''
for item in tree.get_children():
tree.delete(item)
current_key = self._ollama_current_key()
for row in rows:
host = row.get('host', '')
port = row.get('port', '') or DEFAULT_OLLAMA_PORT
models = row.get('models', []) or []
key = f"{host}:{port}"
self._ollama_model_options[key] = models
# Initialize default selection if missing
if key not in self._ollama_default_model_by_key:
self._ollama_default_model_by_key[key] = (models[0] if models else '')
prefix = ' [✓] ' if key == current_key else ' [ ] '
models_preview = ', '.join(models) if models else '—'
default_model = self._ollama_default_model_by_key.get(key, '') or '—'
try:
tree.insert('', 'end', values=(prefix, host, port, default_model, models_preview))
except Exception:
# Fallback if columns not updated yet
tree.insert('', 'end', values=(prefix, host, port, models_preview))
if current_key:
for item in tree.get_children():
vals = tree.item(item, 'values')
if vals and f"{vals[1]}:{vals[2]}" == current_key:
tree.selection_set(item)
tree.see(item)
break
note_item = getattr(self, '_ollama_note_item', None)
if note_item and self.bg_canvas.winfo_exists():
msg = notice if notice else ""
self.bg_canvas.itemconfig(note_item, text=msg)
models = self._ollama_model_options.get(current_key, [])
if models:
self._set_ollama_model_values(models)
def _refresh_ollama_table(self):
if getattr(self, '_refreshing_ollama_table', False):
return
self._refreshing_ollama_table = True
self._ollama_model_options = {}
if getattr(self, '_ollama_status_var', None):
self._ollama_status_var.set("Scanning...")
def worker():
notice = ''
rows: list[dict] = []
try:
script_path = PROJECT_ROOT / "etc" / "init_ollama.sh"
if not script_path.exists():
notice = f"Missing script: {script_path}"
self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None)
else:
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
rc, out = self._run_cmd_capture(['bash', str(script_path), 'scan'], env=env)
if rc == 0:
for line in (out or '').splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split('\t')]
if len(parts) < 2:
continue
host = parts[0]
port = parts[1] or DEFAULT_OLLAMA_PORT
model_list: list[str] = []
if len(parts) > 2 and parts[2]:
model_list = [m.strip() for m in parts[2].split(',') if m.strip() and m.strip() != '-']
rows.append({'host': host, 'port': port, 'models': model_list})
# Deduplicate by canonical host:port while preferring hostname labels
if rows:
def _is_ip(name: str) -> bool:
if not name:
return False
return bool(re.match(r'^(?:\d{1,3}\.){3}\d{1,3}$', name)) or ':' in name
def _canon_host(name: str) -> str:
n = (name or '').strip().lower()
if n in {'localhost', '127.0.0.1', '::1', 'k3d.local'}:
return '127.0.0.1'
try:
# Prefer IPv4
return socket.gethostbyname(n)
except Exception:
return n
def _label_rank(name: str) -> int:
n = (name or '').strip().lower()
if n == 'k3d.local':
return 0
if not _is_ip(n) and n not in {'localhost'}:
return 1 # other hostnames preferred after k3d.local
if n == 'localhost':
return 2
return 3 # IPs last
merged: dict[tuple[str, str], dict] = {}
for r in rows:
disp_host = r.get('host', '')
port = str(r.get('port', '') or DEFAULT_OLLAMA_PORT)
canon = _canon_host(disp_host)
key = (canon, port)
cur = merged.get(key)
if not cur:
merged[key] = {
'host': disp_host, # display label
'port': port,
'models': list(r.get('models', []) or [])
}
else:
# Merge models
existing = set(cur.get('models', []) or [])
for m in (r.get('models', []) or []):
if m not in existing:
cur['models'].append(m)
existing.add(m)
# Pick better display host
old_label = cur.get('host', '')
if _label_rank(disp_host) < _label_rank(old_label):
cur['host'] = disp_host
# Convert back to list and sort by host
rows = sorted(merged.values(), key=lambda d: (d.get('host') or ''))
if rows:
notice = f"Detected {len(rows)} server(s)."
self.safe_after(lambda: self._ollama_status_var.set("Scan complete") if getattr(self, '_ollama_status_var', None) else None)
else:
notice = "No Ollama servers detected."
self.safe_after(lambda: self._ollama_status_var.set("No servers found") if getattr(self, '_ollama_status_var', None) else None)
else:
notice = "Scan failed. See logs for details."
self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None)
except Exception as e:
notice = f"Scan error: {e}"
self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None)
finally:
def finish():
self._refreshing_ollama_table = False
self._update_ollama_table(rows, notice)
self.safe_after(finish)
threading.Thread(target=worker, daemon=True).start()
def _save_ollama_config(self):
raw_host = (self.ollama_server_host.get() or '').strip()
raw_port = (self.ollama_server_port.get() or '').strip()
raw_model = (self.ollama_model.get() or '').strip()
host = raw_host
port = raw_port
if raw_host:
parsed_host, parsed_port = _parse_ollama_host(raw_host)
if parsed_host:
host = parsed_host
if not port and parsed_port:
port = parsed_port
if host and not port:
port = DEFAULT_OLLAMA_PORT
if host and host != raw_host:
self.ollama_server_host.set(host)
if port and port != raw_port:
self.ollama_server_port.set(port)
data = self.prole_cfg_data.setdefault('Ollama', {})
if host:
data['OLLAMA_SERVER_HOST'] = host
data['OLLAMA_SERVER_PORT'] = port
data['OLLAMA_HOST'] = _format_ollama_host(host, port)
else:
data.pop('OLLAMA_SERVER_HOST', None)
data.pop('OLLAMA_SERVER_PORT', None)
data.pop('OLLAMA_HOST', None)
if raw_model:
data['OLLAMA_MODEL'] = raw_model
else:
data.pop('OLLAMA_MODEL', None)
self._save_prole_cfg()
if getattr(self, '_ollama_status_var', None):
self._ollama_status_var.set("Saved")
def _apply_ollama_defaults(self):
env_host = (os.environ.get('OLLAMA_SERVER_HOST') or '').strip()
env_port = (os.environ.get('OLLAMA_SERVER_PORT') or '').strip()
env_model = (os.environ.get('OLLAMA_MODEL') or '').strip()
env_url = (os.environ.get('OLLAMA_HOST') or '').strip()
if env_url and not env_host:
parsed_host, parsed_port = _parse_ollama_host(env_url)
if parsed_host:
env_host = parsed_host
if parsed_port and not env_port:
env_port = parsed_port
if env_host and not self.ollama_server_host.get().strip():
self.ollama_server_host.set(env_host)
if env_port and not self.ollama_server_port.get().strip():
self.ollama_server_port.set(env_port)
if env_model and not self.ollama_model.get().strip():
self.ollama_model.set(env_model)
cfg_host, cfg_port, cfg_model = self._read_ollama_cfg_values()
if cfg_host and not self.ollama_server_host.get().strip():
self.ollama_server_host.set(cfg_host)
if cfg_port and not self.ollama_server_port.get().strip():
self.ollama_server_port.set(cfg_port)
if cfg_model and not self.ollama_model.get().strip():
self.ollama_model.set(cfg_model)
def _read_ollama_cfg_values(self) -> tuple[str, str, str]:
"""Return (host, port, model) from prole.cfg if present."""
cfg_path = None
try:
if self._cfg_path_override is not None:
cfg_path = self._cfg_path_override
if cfg_path.is_dir():
cfg_path = cfg_path / 'prole.cfg'
else:
cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg'
except Exception:
cfg_path = None
if not cfg_path or not Path(cfg_path).exists():
return '', '', ''
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
try:
cfg.read(cfg_path)
except Exception:
return '', '', ''
cfg_vars = _collect_cfg_vars(cfg)
host_val = ''
port_val = ''
model_val = ''
host_url = ''
if cfg.has_section('Ollama'):
sec = cfg['Ollama']
host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip()
port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip()
model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip()
host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip()
if cfg.has_section('Global'):
sec = cfg['Global']
if not host_val:
host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip()
if not port_val:
port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip()
if not model_val:
model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip()
if not host_url:
host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip()
if host_url and not host_val:
parsed_host, parsed_port = _parse_ollama_host(host_url)
if parsed_host:
host_val = parsed_host
if parsed_port and not port_val:
port_val = parsed_port
return host_val, port_val, model_val