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:
parent
7a82db4876
commit
75a3cd0826
@ -1,6 +1,5 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||
import { OLLAMA_URL } from '../../../lib/ollama-config';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||
import { OLLAMA_URL } from '../../lib/ollama-config';
|
||||
|
||||
async function fetchOllama(path: string, options?: RequestInit) {
|
||||
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) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
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') {
|
||||
await fetchOllama('/api/generate', {
|
||||
method: 'POST',
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||
import { OLLAMA_URL } from '../../../lib/ollama-config';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { exec } from 'child_process';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Cache slow commands (chip, gpu, brew don't change during a session)
|
||||
let staticCache: {
|
||||
@ -64,7 +65,7 @@ async function getBrewPackages(): Promise<Array<{ name: string; version: string
|
||||
const results: Array<{ name: string; version: string }> = [];
|
||||
for (const pkg of targets) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`brew list --versions ${pkg} 2>/dev/null`, {
|
||||
const { stdout } = await execFileAsync('brew', ['list', '--versions', pkg], {
|
||||
timeout: 3000,
|
||||
});
|
||||
const parts = stdout.trim().split(' ');
|
||||
|
||||
18
__LOCAL_LLMs/dashboard/src/app/components/ProgressBar.tsx
Normal file
18
__LOCAL_LLMs/dashboard/src/app/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
__LOCAL_LLMs/dashboard/src/app/components/StatusDot.tsx
Normal file
10
__LOCAL_LLMs/dashboard/src/app/components/StatusDot.tsx
Normal 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]}`} />;
|
||||
}
|
||||
50
__LOCAL_LLMs/dashboard/src/app/error.tsx
Normal file
50
__LOCAL_LLMs/dashboard/src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
__LOCAL_LLMs/dashboard/src/app/lib/format.ts
Normal file
16
__LOCAL_LLMs/dashboard/src/app/lib/format.ts
Normal 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`;
|
||||
}
|
||||
1
__LOCAL_LLMs/dashboard/src/app/lib/ollama-config.ts
Normal file
1
__LOCAL_LLMs/dashboard/src/app/lib/ollama-config.ts
Normal file
@ -0,0 +1 @@
|
||||
export const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||
75
__LOCAL_LLMs/dashboard/src/app/lib/types.ts
Normal file
75
__LOCAL_LLMs/dashboard/src/app/lib/types.ts
Normal 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;
|
||||
}
|
||||
@ -27,113 +27,17 @@ import {
|
||||
Send,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
modified_at: string;
|
||||
details?: {
|
||||
family?: string;
|
||||
parameter_size?: string;
|
||||
quantization_level?: string;
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
import type {
|
||||
OllamaData,
|
||||
WhisperData,
|
||||
SystemData,
|
||||
Toast,
|
||||
PullProgress,
|
||||
StreamMetrics,
|
||||
} from './lib/types';
|
||||
import { formatBytes, formatUptime } from './lib/format';
|
||||
import { StatusDot } from './components/StatusDot';
|
||||
import { ProgressBar } from './components/ProgressBar';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [ollama, setOllama] = useState<OllamaData | null>(null);
|
||||
@ -150,17 +54,9 @@ export default function Dashboard() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [pullInput, setPullInput] = useState('');
|
||||
const [pullLoading, setPullLoading] = useState(false);
|
||||
const [pullProgress, setPullProgress] = useState<{
|
||||
status: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [pullProgress, setPullProgress] = useState<PullProgress | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [streamMetrics, setStreamMetrics] = useState<{
|
||||
tokensPerSec: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
} | null>(null);
|
||||
const [streamMetrics, setStreamMetrics] = useState<StreamMetrics | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const responseRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user