Add Ollama setup helpers and database backup
This commit is contained in:
@@ -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
|
||||||
|
|||||||
19
src/app/backup/database/route.ts
Normal file
19
src/app/backup/database/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/ollama/pull/route.ts
Normal file
16
src/app/ollama/pull/route.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
13
src/lib/storage.test.ts
Normal 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
20
src/lib/storage.ts
Normal 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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user