diff --git a/README.md b/README.md index 0fcb587..470959c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ This compose stack will: - start the Next.js app on `http://localhost:3000` - persist the SQLite database and pulled model with named Docker volumes +## In-app helpers + +- Use the dashboard runtime panel to refresh Ollama status. +- If the configured model is missing, use `Pull configured model` from the UI. +- Use `Download backup` to export the current SQLite database file. + ## Environment - `DATABASE_URL` - Prisma SQLite connection string diff --git a/src/app/backup/database/route.ts b/src/app/backup/database/route.ts new file mode 100644 index 0000000..eecc870 --- /dev/null +++ b/src/app/backup/database/route.ts @@ -0,0 +1,19 @@ +import { readFile } from "node:fs/promises"; + +import { NextResponse } from "next/server"; + +import { getDatabaseBackupFileName, resolveSqliteDatabasePath } from "@/lib/storage"; + +export async function GET() { + const filePath = resolveSqliteDatabasePath(); + const file = await readFile(filePath); + + return new NextResponse(file, { + status: 200, + headers: { + "Content-Type": "application/x-sqlite3", + "Content-Disposition": `attachment; filename="${getDatabaseBackupFileName()}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/ollama/pull/route.ts b/src/app/ollama/pull/route.ts new file mode 100644 index 0000000..f03a261 --- /dev/null +++ b/src/app/ollama/pull/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +import { OllamaUnavailableError, pullConfiguredOllamaModel } from "@/lib/ollama"; + +export async function POST() { + try { + const result = await pullConfiguredOllamaModel(); + return NextResponse.json(result); + } catch (error) { + if (error instanceof OllamaUnavailableError) { + return NextResponse.json({ error: error.message }, { status: 503 }); + } + + throw error; + } +} diff --git a/src/components/home-dashboard.tsx b/src/components/home-dashboard.tsx index 90c48f8..cd96632 100644 --- a/src/components/home-dashboard.tsx +++ b/src/components/home-dashboard.tsx @@ -44,6 +44,7 @@ export function HomeDashboard() { const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const [insightBusy, setInsightBusy] = useState(false); + const [ollamaBusy, setOllamaBusy] = useState(false); const [ollamaStatus, setOllamaStatus] = useState(null); async function loadDashboard(month: string) { @@ -59,6 +60,12 @@ export function HomeDashboard() { setSnapshot(payload); } + async function loadOllamaStatus() { + const response = await fetch("/ollama/status", { cache: "no-store" }); + const payload = (await response.json()) as OllamaStatus; + setOllamaStatus(payload); + } + useEffect(() => { const timeoutId = window.setTimeout(() => { void loadDashboard(selectedMonth); @@ -69,9 +76,7 @@ export function HomeDashboard() { useEffect(() => { const timeoutId = window.setTimeout(async () => { - const response = await fetch("/ollama/status", { cache: "no-store" }); - const payload = (await response.json()) as OllamaStatus; - setOllamaStatus(payload); + await loadOllamaStatus(); }, 0); return () => window.clearTimeout(timeoutId); @@ -106,6 +111,23 @@ export function HomeDashboard() { await loadDashboard(selectedMonth); } + async function handlePullModel() { + setOllamaBusy(true); + + const response = await fetch("/ollama/pull", { method: "POST" }); + const payload = (await response.json().catch(() => null)) as { error?: string; message?: string } | null; + + setOllamaBusy(false); + + if (!response.ok) { + setError(payload?.error ?? "Could not pull the configured model."); + return; + } + + setError(payload?.message ?? null); + await loadOllamaStatus(); + } + return (
@@ -211,6 +233,31 @@ export function HomeDashboard() { URL: {ollamaStatus?.configuredUrl ?? "-"}

+
+ + {ollamaStatus?.available && !ollamaStatus.modelReady ? ( + + ) : null} + + Download backup + +
{snapshot?.insight ? ( diff --git a/src/lib/ollama.test.ts b/src/lib/ollama.test.ts index 2fd3c58..8a8d798 100644 --- a/src/lib/ollama.test.ts +++ b/src/lib/ollama.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { generateOllamaJson, getOllamaStatus } from "@/lib/ollama"; +import { generateOllamaJson, getOllamaStatus, pullConfiguredOllamaModel } from "@/lib/ollama"; describe("getOllamaStatus", () => { afterEach(() => { @@ -36,3 +36,20 @@ describe("generateOllamaJson", () => { expect(result.summary).toBe("ok"); }); }); + +describe("pullConfiguredOllamaModel", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("requests a pull for the configured model", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ status: "success" }), + } as Response); + + const result = await pullConfiguredOllamaModel(); + + expect(result.model).toBe("qwen3.5:9b"); + }); +}); diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts index d723084..1a82d07 100644 --- a/src/lib/ollama.ts +++ b/src/lib/ollama.ts @@ -71,6 +71,31 @@ export async function getOllamaStatus(): Promise { } } +export async function pullConfiguredOllamaModel() { + const { baseUrl, model } = getOllamaConfig(); + + let response: Response; + + try { + response = await fetch(`${baseUrl}/api/pull`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, stream: false }), + }); + } catch { + throw new OllamaUnavailableError("Ollama is not reachable at the configured URL."); + } + + if (!response.ok) { + throw new OllamaUnavailableError(`Ollama pull failed with status ${response.status}.`); + } + + return { + model, + message: `${model} is available for offline use.`, + }; +} + export async function generateOllamaJson({ prompt, model }: GenerateJsonInput): Promise { const { baseUrl, model: configuredModel } = getOllamaConfig(); const selectedModel = model ?? configuredModel; diff --git a/src/lib/storage.test.ts b/src/lib/storage.test.ts new file mode 100644 index 0000000..04fc137 --- /dev/null +++ b/src/lib/storage.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSqliteDatabasePath } from "@/lib/storage"; + +describe("resolveSqliteDatabasePath", () => { + it("resolves relative sqlite paths from the prisma directory", () => { + expect(resolveSqliteDatabasePath("file:./dev.db")).toContain("/prisma/dev.db"); + }); + + it("preserves absolute sqlite paths", () => { + expect(resolveSqliteDatabasePath("file:/data/dev.db")).toBe("/data/dev.db"); + }); +}); diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..a690d79 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,20 @@ +import path from "node:path"; + +export function resolveSqliteDatabasePath(databaseUrl = process.env.DATABASE_URL ?? "file:./dev.db") { + if (!databaseUrl.startsWith("file:")) { + throw new Error("Only SQLite file DATABASE_URL values are supported."); + } + + const rawPath = databaseUrl.slice("file:".length); + + if (path.isAbsolute(rawPath)) { + return rawPath; + } + + return path.resolve(process.cwd(), "prisma", rawPath); +} + +export function getDatabaseBackupFileName() { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `monthytracker-backup-${timestamp}.db`; +}