Add Ollama setup helpers and database backup

This commit is contained in:
2026-03-23 14:27:53 -04:00
parent 3bc8550f12
commit 28c5ad959f
8 changed files with 167 additions and 4 deletions

View File

@@ -44,6 +44,12 @@ This compose stack will:
- start the Next.js app on `http://localhost:3000` - start the Next.js app on `http://localhost:3000`
- persist the SQLite database and pulled model with named Docker volumes - 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 ## Environment
- `DATABASE_URL` - Prisma SQLite connection string - `DATABASE_URL` - Prisma SQLite connection string

View File

@@ -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",
},
});
}

View File

@@ -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;
}
}

View File

@@ -44,6 +44,7 @@ export function HomeDashboard() {
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null); const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [insightBusy, setInsightBusy] = useState(false); const [insightBusy, setInsightBusy] = useState(false);
const [ollamaBusy, setOllamaBusy] = useState(false);
const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null); const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null);
async function loadDashboard(month: string) { async function loadDashboard(month: string) {
@@ -59,6 +60,12 @@ export function HomeDashboard() {
setSnapshot(payload); setSnapshot(payload);
} }
async function loadOllamaStatus() {
const response = await fetch("/ollama/status", { cache: "no-store" });
const payload = (await response.json()) as OllamaStatus;
setOllamaStatus(payload);
}
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
void loadDashboard(selectedMonth); void loadDashboard(selectedMonth);
@@ -69,9 +76,7 @@ export function HomeDashboard() {
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(async () => { const timeoutId = window.setTimeout(async () => {
const response = await fetch("/ollama/status", { cache: "no-store" }); await loadOllamaStatus();
const payload = (await response.json()) as OllamaStatus;
setOllamaStatus(payload);
}, 0); }, 0);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
@@ -106,6 +111,23 @@ export function HomeDashboard() {
await loadDashboard(selectedMonth); 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 ( return (
<div className="space-y-10"> <div className="space-y-10">
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]"> <section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
@@ -211,6 +233,31 @@ export function HomeDashboard() {
URL: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredUrl ?? "-"}</span> URL: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredUrl ?? "-"}</span>
</p> </p>
</div> </div>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={() => void loadOllamaStatus()}
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
>
Refresh status
</button>
{ollamaStatus?.available && !ollamaStatus.modelReady ? (
<button
type="button"
onClick={() => void handlePullModel()}
disabled={ollamaBusy}
className="rounded-full bg-stone-950 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{ollamaBusy ? "Pulling model..." : "Pull configured model"}
</button>
) : null}
<a
href="/backup/database"
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
>
Download backup
</a>
</div>
</div> </div>
{snapshot?.insight ? ( {snapshot?.insight ? (

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { generateOllamaJson, getOllamaStatus } from "@/lib/ollama"; import { generateOllamaJson, getOllamaStatus, pullConfiguredOllamaModel } from "@/lib/ollama";
describe("getOllamaStatus", () => { describe("getOllamaStatus", () => {
afterEach(() => { afterEach(() => {
@@ -36,3 +36,20 @@ describe("generateOllamaJson", () => {
expect(result.summary).toBe("ok"); 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");
});
});

View File

@@ -71,6 +71,31 @@ export async function getOllamaStatus(): Promise<OllamaStatus> {
} }
} }
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<T>({ prompt, model }: GenerateJsonInput): Promise<T> { export async function generateOllamaJson<T>({ prompt, model }: GenerateJsonInput): Promise<T> {
const { baseUrl, model: configuredModel } = getOllamaConfig(); const { baseUrl, model: configuredModel } = getOllamaConfig();
const selectedModel = model ?? configuredModel; const selectedModel = model ?? configuredModel;

13
src/lib/storage.test.ts Normal file
View File

@@ -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");
});
});

20
src/lib/storage.ts Normal file
View File

@@ -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`;
}