BareGit

model-switcher: add systemd service log viewer to web interface

Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Jan 6 22:00:31 2026 -0800
Commit: 94103c4c583223d8b8d25aef23ed0c5db4c6b7ca

Changes

diff --git a/model-switcher/main.py b/model-switcher/main.py
index 36607a4..9f79b3c 100644
--- a/model-switcher/main.py
+++ b/model-switcher/main.py
@@ -64,6 +64,8 @@ class RequestHandler(http.server.BaseHTTPRequestHandler):
     def do_GET(self):
         if self.path == "/api/models":
             self.handleGetModels()
+        elif self.path == "/api/logs":
+            self.handleGetLogs()
         else:
             self.serveStatic()
 
@@ -108,6 +110,26 @@ class RequestHandler(http.server.BaseHTTPRequestHandler):
         except Exception as e:
             self.send_json_response(500, {"error": str(e)})
 
+    def handleGetLogs(self):
+        try:
+            # -u: service name
+            # -n 100: last 100 lines
+            # --no-pager: don't pipe to less/more
+            # -r: reverse order (newest first)
+            cmd = ["journalctl", "-u", SERVICE_NAME, "-n", "100", "--no-pager", "-r"]
+            result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+            
+            if result.returncode != 0:
+                # If journalctl fails, it might be due to permissions or service not found
+                # We return the error message
+                logs = [f"Error fetching logs: {result.stderr.strip() or 'Unknown error'}"]
+            else:
+                logs = result.stdout.splitlines()
+
+            self.send_json_response(200, {"logs": logs})
+        except Exception as e:
+            self.send_json_response(500, {"error": str(e)})
+
     def handleSwitchModel(self):
         content_length = int(self.headers.get('Content-Length', 0))
         post_data = self.rfile.read(content_length)
diff --git a/model-switcher/static/index.html b/model-switcher/static/index.html
index 4c8755e..88667e2 100644
--- a/model-switcher/static/index.html
+++ b/model-switcher/static/index.html
@@ -23,6 +23,10 @@
             const [loading, setLoading] = useState(true);
             const [switching, setSwitching] = useState(false);
             const [message, setMessage] = useState({ text: '', type: '' });
+            
+            // Log state
+            const [logs, setLogs] = useState([]);
+            const [loadingLogs, setLoadingLogs] = useState(false);
 
             const fetchModels = async () => {
                 try {
@@ -41,6 +45,23 @@
                 }
             };
 
+            const fetchLogs = async () => {
+                try {
+                    setLoadingLogs(true);
+                    const response = await fetch('/api/logs');
+                    const data = await response.json();
+                    if (data.error) throw new Error(data.error);
+                    
+                    setLogs(data.logs || []);
+                } catch (err) {
+                    // Just log to console or show a small error in the log section?
+                    // We'll put a fake log entry for error
+                    setLogs(['Error fetching logs: ' + err.message]);
+                } finally {
+                    setLoadingLogs(false);
+                }
+            };
+
             const switchModel = async () => {
                 if (!selectedModel || selectedModel === currentModel) return;
 
@@ -59,6 +80,8 @@
                     
                     setMessage({ text: 'Model switched successfully!', type: 'success' });
                     setCurrentModel(selectedModel);
+                    // Refresh logs after switch
+                    setTimeout(fetchLogs, 2000); 
                 } catch (err) {
                     setMessage({ text: 'Failed to switch model: ' + err.message, type: 'error' });
                 } finally {
@@ -68,6 +91,7 @@
 
             useEffect(() => {
                 fetchModels();
+                fetchLogs();
             }, []);
 
             if (loading) return html`<div class="container"><p>Loading models...</p></div>`;
@@ -109,7 +133,7 @@
                             >
                                 ${switching ? 'Restarting...' : 'Apply Selection'}
                             </button>
-                            <button class="secondary" onClick="${fetchModels}" disabled="${switching}">Refresh</button>
+                            <button class="secondary" onClick="${fetchModels}" disabled="${switching}">Refresh Models</button>
                         </div>
 
                         ${message.text && html`
@@ -117,6 +141,21 @@
                                 ${message.text}
                             </div>
                         `}
+
+                        <div class="log-section">
+                            <div class="log-header">
+                                <h2>Service Logs</h2>
+                                <button class="secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.8rem;" onClick="${fetchLogs}" disabled="${loadingLogs}">
+                                    ${loadingLogs ? 'Refreshing...' : 'Refresh Logs'}
+                                </button>
+                            </div>
+                            <div class="log-viewer">
+                                ${logs.length === 0 
+                                    ? html`<div class="log-entry">No logs available or service not found.</div>` 
+                                    : logs.map((line, i) => html`<div class="log-entry" key="${i}">${line}</div>`)
+                                }
+                            </div>
+                        </div>
                     </main>
                 </div>
             `;
@@ -125,4 +164,4 @@
         render(html`<${App} />`, document.getElementById('app'));
     </script>
 </body>
-</html>
+</html>
\ No newline at end of file
diff --git a/model-switcher/static/style.css b/model-switcher/static/style.css
index a66f781..0387917 100644
--- a/model-switcher/static/style.css
+++ b/model-switcher/static/style.css
@@ -9,6 +9,8 @@
     --error-color: #ef4444;
     --info-color: #3b82f6;
     --border-color: #e2e8f0;
+    --log-bg: #1e293b;
+    --log-text: #e2e8f0;
 }
 
 body {
@@ -111,6 +113,7 @@ code {
 .actions {
     display: flex;
     gap: 1rem;
+    margin-bottom: 1.5rem;
 }
 
 button {
@@ -168,3 +171,59 @@ button:disabled {
     animation: spin 1s linear infinite;
     margin-left: 8px;
 }
+
+/* Log Viewer Styles */
+.log-section {
+    margin-top: 2rem;
+    border-top: 1px solid var(--border-color);
+    padding-top: 1rem;
+}
+
+.log-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 0.5rem;
+}
+
+.log-header h2 {
+    font-size: 1.2rem;
+    color: var(--text-color);
+    margin: 0;
+}
+
+.log-viewer {
+    background-color: var(--log-bg);
+    color: var(--log-text);
+    padding: 1rem;
+    border-radius: 8px;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+    font-size: 0.85rem;
+    height: 300px;
+    overflow-y: auto;
+    white-space: pre-wrap;
+    word-break: break-all;
+    display: flex;
+    flex-direction: column-reverse; /* Since we get logs reverse (newest first), but typically logs are read top-to-bottom or bottom-to-top. 
+                                      The user said "newest line first". So top of the container should be newest.
+                                      Wait, if I get a list ["newest", "2nd newest", ...], simply rendering them in order will put newest at top.
+                                      So column-reverse might NOT be needed if I just render them in the order I receive them.
+                                      Actually, usually log viewers put newest at the bottom and auto-scroll. 
+                                      But the user specifically requested "newest line first".
+                                      This implies:
+                                      Line 1: (Newest)
+                                      Line 2: (Older)
+                                      ...
+                                      So I should render them in the order I receive them (since journalctl -r gives newest first).
+                                   */
+    flex-direction: column; 
+}
+
+.log-entry {
+    padding: 2px 0;
+    border-bottom: 1px solid rgba(255,255,255,0.1);
+}
+
+.log-entry:last-child {
+    border-bottom: none;
+}
\ No newline at end of file