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';
|
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 {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(' ');
|
||||||
|
|||||||
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,
|
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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user