Add Ollama setup helpers and database backup
This commit is contained in:
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 [error, setError] = useState<string | null>(null);
|
||||
const [insightBusy, setInsightBusy] = useState(false);
|
||||
const [ollamaBusy, setOllamaBusy] = useState(false);
|
||||
const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(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 (
|
||||
<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]">
|
||||
@@ -211,6 +233,31 @@ export function HomeDashboard() {
|
||||
URL: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredUrl ?? "-"}</span>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{snapshot?.insight ? (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
const { baseUrl, model: configuredModel } = getOllamaConfig();
|
||||
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