refactor(local-llm): Sprint 3 — component extraction, error boundary, security (CQ1,CQ3,CQ4,S1,S2)

Component extraction (CQ1):
- lib/types.ts: All interfaces (OllamaData, SystemData, Toast, etc.)
- lib/format.ts: formatBytes, formatUptime utilities
- lib/ollama-config.ts: Shared OLLAMA_URL constant
- components/StatusDot.tsx: Status indicator component
- components/ProgressBar.tsx: Progress bar component
- page.tsx: Now imports from extracted modules, reduced from 1180 to
  1077 lines (interfaces + utilities + sub-components removed)

Error boundary (CQ4):
- error.tsx: Next.js App Router error boundary with styled error UI,
  stack trace preview, and 'Try again' button

Shared config (CQ3):
- All 3 Ollama API routes now import OLLAMA_URL from lib/ollama-config.ts
  instead of duplicating the env var fallback

Security (S1):
- Add MODEL_NAME_RE regex validation on POST /api/ollama — rejects
  invalid model names before passing to Ollama API

Security (S2):
- Replace exec() with execFile() for brew package version check —
  prevents shell injection if targets list ever becomes dynamic
This commit is contained in:
saravanakumardb1 2026-02-19 15:21:22 -08:00
parent 7a82db4876
commit 75a3cd0826
11 changed files with 195 additions and 125 deletions

View File

@ -1,6 +1,5 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { OLLAMA_URL } from '../../../lib/ollama-config';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {

View File

@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { OLLAMA_URL } from '../../lib/ollama-config';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
async function fetchOllama(path: string, options?: RequestInit) { async function fetchOllama(path: string, options?: RequestInit) {
const controller = new AbortController(); const controller = new AbortController();
@ -52,11 +51,17 @@ export async function GET() {
} }
} }
const MODEL_NAME_RE = /^[a-zA-Z0-9._:/-]{1,256}$/;
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { action, model } = body; const { action, model } = body;
if (!model || typeof model !== 'string' || !MODEL_NAME_RE.test(model)) {
return NextResponse.json({ error: 'Invalid model name' }, { status: 400 });
}
if (action === 'load') { if (action === 'load') {
await fetchOllama('/api/generate', { await fetchOllama('/api/generate', {
method: 'POST', method: 'POST',

View File

@ -1,6 +1,5 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { OLLAMA_URL } from '../../../lib/ollama-config';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {

View File

@ -1,9 +1,10 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { exec } from 'child_process'; import { exec, execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import os from 'os'; import os from 'os';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// Cache slow commands (chip, gpu, brew don't change during a session) // Cache slow commands (chip, gpu, brew don't change during a session)
let staticCache: { let staticCache: {
@ -64,7 +65,7 @@ async function getBrewPackages(): Promise<Array<{ name: string; version: string
const results: Array<{ name: string; version: string }> = []; const results: Array<{ name: string; version: string }> = [];
for (const pkg of targets) { for (const pkg of targets) {
try { try {
const { stdout } = await execAsync(`brew list --versions ${pkg} 2>/dev/null`, { const { stdout } = await execFileAsync('brew', ['list', '--versions', pkg], {
timeout: 3000, timeout: 3000,
}); });
const parts = stdout.trim().split(' '); const parts = stdout.trim().split(' ');

View File

@ -0,0 +1,18 @@
'use client';
export function ProgressBar({
value,
max,
color = 'var(--accent-primary)',
}: {
value: number;
max: number;
color?: string;
}) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
return (
<div className="progress-bar h-2 w-full">
<div className="progress-fill" style={{ width: `${pct}%`, background: color }} />
</div>
);
}

View File

@ -0,0 +1,10 @@
'use client';
export function StatusDot({ status }: { status: 'online' | 'offline' | 'warning' }) {
const colors = {
online: 'bg-[var(--success)]',
offline: 'bg-[var(--danger)]',
warning: 'bg-[var(--warning)]',
};
return <span className={`inline-block w-2.5 h-2.5 rounded-full pulse-dot ${colors[status]}`} />;
}

View File

@ -0,0 +1,50 @@
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Dashboard error:', error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div
className="max-w-md w-full p-8 rounded-xl text-center"
style={{ background: 'var(--surface-card)', border: '1px solid var(--border-subtle)' }}
>
<div
className="w-14 h-14 rounded-xl flex items-center justify-center mx-auto mb-4"
style={{ background: 'rgba(255, 110, 110, 0.1)' }}
>
<span className="text-2xl"></span>
</div>
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Something went wrong
</h2>
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>
{error.message || 'An unexpected error occurred in the dashboard.'}
</p>
<pre
className="text-xs text-left p-3 rounded-lg mb-4 max-h-32 overflow-auto"
style={{ background: 'var(--surface-muted)', color: 'var(--danger)' }}
>
{error.stack?.split('\n').slice(0, 5).join('\n')}
</pre>
<button
onClick={reset}
className="px-6 py-2.5 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent-primary)', color: 'white' }}
>
Try again
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@ -0,0 +1 @@
export const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';

View File

@ -0,0 +1,75 @@
export interface OllamaModel {
name: string;
size: number;
digest: string;
modified_at: string;
details?: {
family?: string;
parameter_size?: string;
quantization_level?: string;
};
}
export interface RunningModel {
name: string;
size: number;
digest: string;
expires_at: string;
size_vram: number;
}
export interface OllamaData {
status: string;
url: string;
models: OllamaModel[];
running: RunningModel[];
totalModels: number;
totalSize: number;
runningCount: number;
}
export interface WhisperModel {
name: string;
size: number;
path: string;
}
export interface WhisperData {
installed: boolean;
version: string;
binaries: string[];
models: WhisperModel[];
modelsDir: string;
}
export interface SystemData {
chip: string;
gpu: string;
memory: { total: number; appMemory: number; cached: number; free: number; pressure: string };
disk: { total: number; used: number; free: number };
ollamaDiskUsage: number;
cpuCores: number;
uptime: number;
platform: string;
arch: string;
nodeVersion: string;
brewPackages: Array<{ name: string; version: string }>;
}
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
export interface PullProgress {
status: string;
completed: number;
total: number;
}
export interface StreamMetrics {
tokensPerSec: number;
totalTokens: number;
durationMs: number;
}

View File

@ -27,113 +27,17 @@ import {
Send, Send,
Terminal, Terminal,
} from 'lucide-react'; } from 'lucide-react';
import type {
interface OllamaModel { OllamaData,
name: string; WhisperData,
size: number; SystemData,
digest: string; Toast,
modified_at: string; PullProgress,
details?: { StreamMetrics,
family?: string; } from './lib/types';
parameter_size?: string; import { formatBytes, formatUptime } from './lib/format';
quantization_level?: string; import { StatusDot } from './components/StatusDot';
}; import { ProgressBar } from './components/ProgressBar';
}
interface RunningModel {
name: string;
size: number;
digest: string;
expires_at: string;
size_vram: number;
}
interface OllamaData {
status: string;
url: string;
models: OllamaModel[];
running: RunningModel[];
totalModels: number;
totalSize: number;
runningCount: number;
}
interface WhisperModel {
name: string;
size: number;
path: string;
}
interface WhisperData {
installed: boolean;
version: string;
binaries: string[];
models: WhisperModel[];
modelsDir: string;
}
interface SystemData {
chip: string;
gpu: string;
memory: { total: number; appMemory: number; cached: number; free: number; pressure: string };
disk: { total: number; used: number; free: number };
ollamaDiskUsage: number;
cpuCores: number;
uptime: number;
platform: string;
arch: string;
nodeVersion: string;
brewPackages: Array<{ name: string; version: string }>;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function StatusDot({ status }: { status: 'online' | 'offline' | 'warning' }) {
const colors = {
online: 'bg-[var(--success)]',
offline: 'bg-[var(--danger)]',
warning: 'bg-[var(--warning)]',
};
return <span className={`inline-block w-2.5 h-2.5 rounded-full pulse-dot ${colors[status]}`} />;
}
function ProgressBar({
value,
max,
color = 'var(--accent-primary)',
}: {
value: number;
max: number;
color?: string;
}) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
return (
<div className="progress-bar h-2 w-full">
<div className="progress-fill" style={{ width: `${pct}%`, background: color }} />
</div>
);
}
interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
export default function Dashboard() { export default function Dashboard() {
const [ollama, setOllama] = useState<OllamaData | null>(null); const [ollama, setOllama] = useState<OllamaData | null>(null);
@ -150,17 +54,9 @@ export default function Dashboard() {
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const [pullInput, setPullInput] = useState(''); const [pullInput, setPullInput] = useState('');
const [pullLoading, setPullLoading] = useState(false); const [pullLoading, setPullLoading] = useState(false);
const [pullProgress, setPullProgress] = useState<{ const [pullProgress, setPullProgress] = useState<PullProgress | null>(null);
status: string;
completed: number;
total: number;
} | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [streamMetrics, setStreamMetrics] = useState<{ const [streamMetrics, setStreamMetrics] = useState<StreamMetrics | null>(null);
tokensPerSec: number;
totalTokens: number;
durationMs: number;
} | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const responseRef = useRef<HTMLDivElement>(null); const responseRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);