generated from Dicken/dickendock
Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung
This commit is contained in:
@@ -64,6 +64,7 @@ export function ensureSchema(): void {
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
"order" REAL NOT NULL DEFAULT 0,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -92,14 +93,36 @@ export function ensureSchema(): void {
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_visits (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_type TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
visited_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS read_later (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
favicon TEXT,
|
||||
saved_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Leichte Migration für Datenbanken, die vor Einführung von "visible"
|
||||
// angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits
|
||||
// Leichte Migration für Datenbanken, die vor Einführung von "visible"/
|
||||
// "color" angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits
|
||||
// existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier
|
||||
// manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit
|
||||
// sich am bisherigen Verhalten nichts unerwartet ändert.
|
||||
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("categories", "color", "TEXT");
|
||||
}
|
||||
|
||||
function ensureColumn(table: string, column: string, definition: string): void {
|
||||
|
||||
@@ -85,6 +85,7 @@ export function updateBookmark(id: string, input: BookmarkUpdateInput): Bookmark
|
||||
...(input.description !== undefined && { description: input.description }),
|
||||
...(input.category !== undefined && { category: input.category }),
|
||||
...(input.icon !== undefined && { icon: input.icon }),
|
||||
...(input.favicon !== undefined && { favicon: input.favicon }),
|
||||
...(input.favorite !== undefined && { favorite: input.favorite }),
|
||||
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
|
||||
...(input.order !== undefined && { order: input.order }),
|
||||
|
||||
@@ -14,7 +14,7 @@ function nowIso(): string {
|
||||
}
|
||||
|
||||
function mapRow(row: typeof categories.$inferSelect): Category {
|
||||
return { id: row.id, name: row.name, order: row.order };
|
||||
return { id: row.id, name: row.name, order: row.order, color: row.color };
|
||||
}
|
||||
|
||||
export function listCategories(): Category[] {
|
||||
@@ -42,6 +42,7 @@ export function createCategory(input: CategoryCreateInput): Category {
|
||||
id,
|
||||
name: input.name,
|
||||
order: nextOrder,
|
||||
color: input.color ?? null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
@@ -71,6 +72,7 @@ export function updateCategory(id: string, input: CategoryUpdateInput): Category
|
||||
db.update(categories)
|
||||
.set({
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.color !== undefined && { color: input.color }),
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
.where(eq(categories.id, id))
|
||||
|
||||
77
apps/backend/src/db/repositories/readLater.ts
Normal file
77
apps/backend/src/db/repositories/readLater.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { readLater } from "../schema.js";
|
||||
|
||||
export interface ReadLaterItem {
|
||||
id: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
favicon: string | null;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function mapRow(row: typeof readLater.$inferSelect): ReadLaterItem {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
displayName: row.displayName,
|
||||
favicon: row.favicon,
|
||||
savedAt: row.savedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listReadLater(): ReadLaterItem[] {
|
||||
return db.select().from(readLater).orderBy(desc(readLater.savedAt)).all().map(mapRow);
|
||||
}
|
||||
|
||||
export function getReadLaterItem(id: string): ReadLaterItem | null {
|
||||
const row = db.select().from(readLater).where(eq(readLater.id, id)).get();
|
||||
return row ? mapRow(row) : null;
|
||||
}
|
||||
|
||||
export function createReadLaterItem(input: {
|
||||
url: string;
|
||||
displayName: string;
|
||||
favicon?: string | null;
|
||||
}): ReadLaterItem {
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
db.insert(readLater)
|
||||
.values({
|
||||
id,
|
||||
url: input.url,
|
||||
displayName: input.displayName,
|
||||
favicon: input.favicon ?? null,
|
||||
savedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
.run();
|
||||
|
||||
return getReadLaterItem(id)!;
|
||||
}
|
||||
|
||||
export function updateReadLaterItem(
|
||||
id: string,
|
||||
input: { url?: string; displayName?: string }
|
||||
): ReadLaterItem | null {
|
||||
const existing = getReadLaterItem(id);
|
||||
if (!existing) return null;
|
||||
|
||||
db.update(readLater)
|
||||
.set({
|
||||
...(input.url !== undefined && { url: input.url }),
|
||||
...(input.displayName !== undefined && { displayName: input.displayName }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(readLater.id, id))
|
||||
.run();
|
||||
|
||||
return getReadLaterItem(id);
|
||||
}
|
||||
|
||||
export function deleteReadLaterItem(id: string): boolean {
|
||||
const result = db.delete(readLater).where(eq(readLater.id, id)).run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
78
apps/backend/src/db/repositories/recentVisits.ts
Normal file
78
apps/backend/src/db/repositories/recentVisits.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Bookmark, Service } from "@launchpad/shared";
|
||||
import { db } from "../client.js";
|
||||
import { recentVisits } from "../schema.js";
|
||||
import * as serviceRepo from "./services.js";
|
||||
import * as bookmarkRepo from "./bookmarks.js";
|
||||
import { getSetting } from "./settings.js";
|
||||
|
||||
export type VisitedItem = (Service & { kind: "service" }) | (Bookmark & { kind: "bookmark" });
|
||||
|
||||
export function recordVisit(itemType: "service" | "bookmark", itemId: string): void {
|
||||
db.insert(recentVisits)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
itemType,
|
||||
itemId,
|
||||
visitedAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 5;
|
||||
|
||||
export function getRecentVisitsLimit(): number {
|
||||
const stored = getSetting("recentVisitsLimit");
|
||||
const parsed = stored ? Number(stored) : DEFAULT_LIMIT;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die zuletzt besuchten Dienste/Lesezeichen, jedes Element nur
|
||||
* einmal (neuester Besuch zählt), aufgelöst zu den echten Objekten. Bereits
|
||||
* gelöschte Ziele werden übersprungen. Limit kommt aus den Einstellungen
|
||||
* (Admin -> Einstellungen), Default 5.
|
||||
*/
|
||||
export function listRecentVisits(limitOverride?: number): VisitedItem[] {
|
||||
const limit = limitOverride ?? getRecentVisitsLimit();
|
||||
|
||||
// Etwas mehr als das Limit an Rohzeilen lesen, falls Duplikate/gelöschte
|
||||
// Ziele darunter sind – reicht in der Praxis (Homelab-Größenordnung) locker.
|
||||
const rows = db
|
||||
.select()
|
||||
.from(recentVisits)
|
||||
.orderBy(desc(recentVisits.visitedAt))
|
||||
.limit(limit * 5 + 20)
|
||||
.all();
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: VisitedItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.itemType}:${row.itemId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (row.itemType === "service") {
|
||||
const service = serviceRepo.getService(row.itemId);
|
||||
if (service) result.push({ ...service, kind: "service" });
|
||||
} else if (row.itemType === "bookmark") {
|
||||
const bookmark = bookmarkRepo.getBookmark(row.itemId);
|
||||
if (bookmark) result.push({ ...bookmark, kind: "bookmark" });
|
||||
}
|
||||
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clearRecentVisits(): void {
|
||||
db.delete(recentVisits).run();
|
||||
}
|
||||
|
||||
/** Räumt Verweise auf eine gelöschte Ressource auf (verhindert totes Wachstum). */
|
||||
export function pruneVisitsFor(itemId: string): void {
|
||||
db.delete(recentVisits).where(eq(recentVisits.itemId, itemId)).run();
|
||||
}
|
||||
22
apps/backend/src/db/repositories/settings.ts
Normal file
22
apps/backend/src/db/repositories/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { appSettings } from "../schema.js";
|
||||
|
||||
export function getSetting(key: string): string | null {
|
||||
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
const existing = getSetting(key);
|
||||
if (existing === null) {
|
||||
db.insert(appSettings).values({ key, value }).run();
|
||||
} else {
|
||||
db.update(appSettings).set({ value }).where(eq(appSettings.key, key)).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function listSettings(): Record<string, string> {
|
||||
const rows = db.select().from(appSettings).all();
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export const categories = sqliteTable("categories", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
order: real("order").notNull().default(0),
|
||||
color: text("color"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
@@ -97,3 +98,34 @@ export const bookmarks = sqliteTable("bookmarks", {
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zuletzt aus der Suche geöffnete Dienste/Lesezeichen, für die
|
||||
* "Zuletzt besucht"-Leiste auf der Startseite.
|
||||
*/
|
||||
export const recentVisits = sqliteTable("recent_visits", {
|
||||
id: text("id").primaryKey(),
|
||||
itemType: text("item_type").notNull(), // "service" | "bookmark"
|
||||
itemId: text("item_id").notNull(),
|
||||
visitedAt: text("visited_at").notNull(),
|
||||
});
|
||||
|
||||
/** Freie Schlüssel-Wert-Einstellungen, z. B. Anzahl "Zuletzt besucht". */
|
||||
export const appSettings = sqliteTable("app_settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* "Später lesen": schnell von der Startseite abgelegte Links, unabhängig
|
||||
* von Lesezeichen/Diensten. Können später zu einem Lesezeichen befördert
|
||||
* oder gelöscht werden.
|
||||
*/
|
||||
export const readLater = sqliteTable("read_later", {
|
||||
id: text("id").primaryKey(),
|
||||
url: text("url").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
favicon: text("favicon"),
|
||||
savedAt: text("saved_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ import { scanRoutes } from "./routes/scan.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { resetRoutes } from "./routes/reset.js";
|
||||
import { transferRoutes } from "./routes/transfer.js";
|
||||
import { bookmarkRoutes } from "./routes/bookmarks.js";
|
||||
import { recentVisitsRoutes } from "./routes/recentVisits.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { readLaterRoutes } from "./routes/readLater.js";
|
||||
import { loadPlugins } from "./plugins/loader.js";
|
||||
import * as serviceRepo from "./db/repositories/services.js";
|
||||
import * as bookmarkRepo from "./db/repositories/bookmarks.js";
|
||||
@@ -64,7 +68,11 @@ async function main() {
|
||||
await app.register(logRoutes);
|
||||
await app.register(pluginRoutes);
|
||||
await app.register(resetRoutes);
|
||||
await app.register(transferRoutes);
|
||||
await app.register(bookmarkRoutes);
|
||||
await app.register(recentVisitsRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(readLaterRoutes);
|
||||
|
||||
app.get("/", async () => {
|
||||
return { name: "LaunchPad API", status: "running" };
|
||||
|
||||
@@ -26,15 +26,19 @@ export async function bookmarkRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
let displayName = parsed.data.displayName;
|
||||
let favicon: string | null = null;
|
||||
let description = parsed.data.description;
|
||||
|
||||
// Titel/Favicon automatisch ziehen, falls kein Name angegeben wurde oder
|
||||
// schlicht um ein Favicon zu bekommen (derselbe Mechanismus wie beim
|
||||
// Netzwerk-Scanner, siehe apps/backend/src/scanner/http.ts).
|
||||
// Titel/Favicon/Beschreibung automatisch ziehen, falls nicht angegeben
|
||||
// (derselbe Mechanismus wie beim Netzwerk-Scanner, siehe
|
||||
// apps/backend/src/scanner/http.ts).
|
||||
try {
|
||||
const probe = await probeHttp(parsed.data.url, 5000);
|
||||
if (!displayName) {
|
||||
displayName = probe.title ?? bookmarkRepo.extractHostname(parsed.data.url);
|
||||
}
|
||||
if (!description) {
|
||||
description = probe.description;
|
||||
}
|
||||
favicon = probe.faviconUrl ?? null;
|
||||
} catch {
|
||||
if (!displayName) {
|
||||
@@ -46,7 +50,10 @@ export async function bookmarkRoutes(app: FastifyInstance): Promise<void> {
|
||||
categoryRepo.ensureCategory(parsed.data.category);
|
||||
}
|
||||
|
||||
const bookmark = bookmarkRepo.createBookmark(parsed.data, { displayName, favicon });
|
||||
const bookmark = bookmarkRepo.createBookmark(
|
||||
{ ...parsed.data, description },
|
||||
{ displayName, favicon }
|
||||
);
|
||||
return reply.code(201).send(bookmark);
|
||||
});
|
||||
|
||||
|
||||
71
apps/backend/src/routes/readLater.ts
Normal file
71
apps/backend/src/routes/readLater.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as readLaterRepo from "../db/repositories/readLater.js";
|
||||
import * as bookmarkRepo from "../db/repositories/bookmarks.js";
|
||||
import { probeHttp } from "../scanner/http.js";
|
||||
|
||||
export async function readLaterRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/read-later", async () => {
|
||||
return readLaterRepo.listReadLater();
|
||||
});
|
||||
|
||||
app.post("/api/read-later", async (request, reply) => {
|
||||
const body = request.body as { url?: string } | undefined;
|
||||
if (!body?.url) {
|
||||
return reply.code(400).send({ error: "url erforderlich" });
|
||||
}
|
||||
|
||||
let name: string;
|
||||
let favicon: string | null = null;
|
||||
try {
|
||||
const probe = await probeHttp(body.url, 5000);
|
||||
name = probe.title ?? new URL(body.url).hostname;
|
||||
favicon = probe.faviconUrl ?? null;
|
||||
} catch {
|
||||
try {
|
||||
name = new URL(body.url).hostname;
|
||||
} catch {
|
||||
name = body.url;
|
||||
}
|
||||
}
|
||||
|
||||
const item = readLaterRepo.createReadLaterItem({ url: body.url, displayName: name, favicon });
|
||||
return reply.code(201).send(item);
|
||||
});
|
||||
|
||||
app.patch("/api/read-later/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as { url?: string; displayName?: string } | undefined;
|
||||
const item = readLaterRepo.updateReadLaterItem(id, body ?? {});
|
||||
if (!item) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
app.delete("/api/read-later/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = readLaterRepo.deleteReadLaterItem(id);
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Verschiebt einen "Später lesen"-Eintrag in die Lesezeichen (als Favorit)
|
||||
// und entfernt ihn aus der Liste.
|
||||
app.post("/api/read-later/:id/promote", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = readLaterRepo.getReadLaterItem(id);
|
||||
if (!item) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
|
||||
const bookmark = bookmarkRepo.createBookmark(
|
||||
{ url: item.url, favorite: true },
|
||||
{ displayName: item.displayName, favicon: item.favicon }
|
||||
);
|
||||
readLaterRepo.deleteReadLaterItem(id);
|
||||
|
||||
return reply.code(201).send(bookmark);
|
||||
});
|
||||
}
|
||||
27
apps/backend/src/routes/recentVisits.ts
Normal file
27
apps/backend/src/routes/recentVisits.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as recentVisitsRepo from "../db/repositories/recentVisits.js";
|
||||
|
||||
export async function recentVisitsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/recent-visits", async (request) => {
|
||||
const query = request.query as { limit?: string };
|
||||
const limit = query.limit ? Number(query.limit) : undefined;
|
||||
return recentVisitsRepo.listRecentVisits(limit);
|
||||
});
|
||||
|
||||
app.post("/api/recent-visits", async (request, reply) => {
|
||||
const body = request.body as { itemType?: string; itemId?: string } | undefined;
|
||||
if (body?.itemType !== "service" && body?.itemType !== "bookmark") {
|
||||
return reply.code(400).send({ error: "itemType muss 'service' oder 'bookmark' sein" });
|
||||
}
|
||||
if (!body.itemId) {
|
||||
return reply.code(400).send({ error: "itemId erforderlich" });
|
||||
}
|
||||
recentVisitsRepo.recordVisit(body.itemType, body.itemId);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/recent-visits", async () => {
|
||||
recentVisitsRepo.clearRecentVisits();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
|
||||
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||
|
||||
const discovered = await scanDeviceServices(device);
|
||||
|
||||
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
|
||||
@@ -60,11 +64,17 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
const updated = results.filter((r) => !r.created).length;
|
||||
const ports = discovered.map((d) => d.port).sort((a, b) => a - b);
|
||||
|
||||
// Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden
|
||||
// (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern
|
||||
// zur manuellen Durchsicht zurückgegeben (siehe Admin -> Scanner).
|
||||
const foundPorts = new Set(discovered.map((d) => d.port));
|
||||
const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port));
|
||||
|
||||
logRepo.logScan({
|
||||
type: "device",
|
||||
targetId: device.id,
|
||||
level: "info",
|
||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert`,
|
||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}`,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -74,6 +84,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
created,
|
||||
updated,
|
||||
services: results.map((r) => r.service),
|
||||
staleServices,
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
@@ -106,6 +117,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
|
||||
|
||||
try {
|
||||
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
|
||||
|
||||
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
|
||||
const devices = hosts.map((h) =>
|
||||
deviceRepo.upsertDeviceFromScan({
|
||||
@@ -117,13 +130,19 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
);
|
||||
|
||||
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||
// mehr in der Liste sind – nicht automatisch gelöscht, nur zur
|
||||
// manuellen Durchsicht zurückgegeben.
|
||||
const foundIps = new Set(hosts.map((h) => h.ip));
|
||||
const staleDevices = devicesBeforeScan.filter((d) => !foundIps.has(d.ip));
|
||||
|
||||
logRepo.logScan({
|
||||
type: "fritzbox",
|
||||
level: "info",
|
||||
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`,
|
||||
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}`,
|
||||
});
|
||||
|
||||
return { found: hosts.length, devices };
|
||||
return { found: hosts.length, devices, staleDevices };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
logRepo.logScan({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as XLSX from "xlsx";
|
||||
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as categoryRepo from "../db/repositories/categories.js";
|
||||
|
||||
interface ServiceListQuery {
|
||||
deviceId?: string;
|
||||
@@ -11,45 +9,6 @@ interface ServiceListQuery {
|
||||
favorite?: string;
|
||||
}
|
||||
|
||||
interface ExportRow {
|
||||
displayName: string;
|
||||
category: string;
|
||||
alias: string;
|
||||
favorite: string;
|
||||
visible: string;
|
||||
order: number;
|
||||
hostname: string;
|
||||
port: number;
|
||||
https: string;
|
||||
url: string;
|
||||
deviceHostname: string;
|
||||
deviceIp: string;
|
||||
}
|
||||
|
||||
function buildExportRows(): ExportRow[] {
|
||||
const services = serviceRepo.listServices();
|
||||
const devices = deviceRepo.listDevices();
|
||||
const deviceById = new Map(devices.map((d) => [d.id, d]));
|
||||
|
||||
return services.map((s) => {
|
||||
const device = deviceById.get(s.deviceId);
|
||||
return {
|
||||
displayName: s.displayName,
|
||||
category: s.category ?? "",
|
||||
alias: s.alias.join(";"),
|
||||
favorite: s.favorite ? "true" : "false",
|
||||
visible: s.visible ? "true" : "false",
|
||||
order: s.order,
|
||||
hostname: s.hostname,
|
||||
port: s.port,
|
||||
https: s.https ? "true" : "false",
|
||||
url: s.url,
|
||||
deviceHostname: device?.hostname ?? "",
|
||||
deviceIp: device?.ip ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/services", async (request) => {
|
||||
const query = request.query as ServiceListQuery;
|
||||
@@ -84,136 +43,6 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(201).send(service);
|
||||
});
|
||||
|
||||
// Müssen vor der /:id-Route stehen, damit "export"/"import" nicht als ID interpretiert wird.
|
||||
app.get("/api/services/export", async (request, reply) => {
|
||||
const query = request.query as { format?: string };
|
||||
const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv";
|
||||
const rows = buildExportRows();
|
||||
|
||||
if (format === "json") {
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-services.json"');
|
||||
reply.type("application/json");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||
|
||||
if (format === "xlsx") {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Dienste");
|
||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-services.xlsx"');
|
||||
reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
return reply.send(buffer);
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-services.csv"');
|
||||
reply.type("text/csv; charset=utf-8");
|
||||
return reply.send(csv);
|
||||
});
|
||||
|
||||
app.post("/api/services/import", async (request, reply) => {
|
||||
const body = request.body as { format?: string; content?: string } | undefined;
|
||||
if (!body?.content) {
|
||||
return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" });
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
if (body.format === "json") {
|
||||
const text = Buffer.from(body.content, "base64").toString("utf-8");
|
||||
rows = JSON.parse(text);
|
||||
} else {
|
||||
const buffer = Buffer.from(body.content, "base64");
|
||||
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: "Datei konnte nicht gelesen werden",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const deviceHostname = String(row.deviceHostname ?? "").trim();
|
||||
const deviceIp = String(row.deviceIp ?? "").trim();
|
||||
const hostname = String(row.hostname ?? deviceHostname).trim();
|
||||
const port = Number(row.port);
|
||||
const displayName = String(row.displayName ?? "").trim();
|
||||
const url = String(row.url ?? "").trim();
|
||||
|
||||
if (!displayName || !hostname || !port || !url) {
|
||||
errors.push(`Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gerät über IP finden, sonst über Hostname, sonst neu anlegen.
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined;
|
||||
if (!device && deviceHostname) {
|
||||
device = allDevices.find((d) => d.hostname === deviceHostname);
|
||||
}
|
||||
if (!device) {
|
||||
device = deviceRepo.createDevice({
|
||||
hostname: deviceHostname || hostname,
|
||||
ip: deviceIp || hostname,
|
||||
});
|
||||
}
|
||||
|
||||
// Bereits vorhanden (gleiches Gerät + Port)? -> überspringen, nicht
|
||||
// doppelt anlegen und nichts Bestehendes verändern/löschen.
|
||||
const existing = serviceRepo.listServicesByDevice(device.id).find((s) => s.port === port);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = row.category ? String(row.category).trim() : undefined;
|
||||
|
||||
serviceRepo.createService({
|
||||
deviceId: device.id,
|
||||
displayName,
|
||||
hostname,
|
||||
url,
|
||||
https: String(row.https ?? "").toLowerCase() === "true",
|
||||
port,
|
||||
category: category || undefined,
|
||||
alias: row.alias
|
||||
? String(row.alias)
|
||||
.split(";")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
favorite: String(row.favorite ?? "").toLowerCase() === "true",
|
||||
order:
|
||||
row.order !== undefined && row.order !== "" ? Number(row.order) : undefined,
|
||||
visible:
|
||||
row.visible !== undefined && row.visible !== ""
|
||||
? String(row.visible).toLowerCase() === "true"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (category) {
|
||||
categoryRepo.ensureCategory(category);
|
||||
}
|
||||
|
||||
imported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
});
|
||||
|
||||
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
|
||||
app.patch("/api/services/reorder", async (request, reply) => {
|
||||
const parsed = ServiceReorderSchema.safeParse(request.body);
|
||||
|
||||
26
apps/backend/src/routes/settings.ts
Normal file
26
apps/backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as settingsRepo from "../db/repositories/settings.js";
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/settings", async () => {
|
||||
const all = settingsRepo.listSettings();
|
||||
return {
|
||||
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
||||
};
|
||||
});
|
||||
|
||||
app.patch("/api/settings", async (request, reply) => {
|
||||
const body = request.body as { recentVisitsLimit?: number } | undefined;
|
||||
|
||||
if (body?.recentVisitsLimit !== undefined) {
|
||||
const value = Number(body.recentVisitsLimit);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 50) {
|
||||
return reply.code(400).send({ error: "recentVisitsLimit muss zwischen 0 und 50 liegen" });
|
||||
}
|
||||
settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value)));
|
||||
}
|
||||
|
||||
const all = settingsRepo.listSettings();
|
||||
return { recentVisitsLimit: Number(all.recentVisitsLimit ?? 5) };
|
||||
});
|
||||
}
|
||||
250
apps/backend/src/routes/transfer.ts
Normal file
250
apps/backend/src/routes/transfer.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as XLSX from "xlsx";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as categoryRepo from "../db/repositories/categories.js";
|
||||
|
||||
interface TransferRow {
|
||||
type: "device" | "service";
|
||||
displayName: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
mac: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
alias: string;
|
||||
favorite: string;
|
||||
visible: string;
|
||||
order: number | string;
|
||||
port: string | number;
|
||||
https: string;
|
||||
url: string;
|
||||
deviceHostname: string;
|
||||
deviceIp: string;
|
||||
}
|
||||
|
||||
function buildExportRows(): TransferRow[] {
|
||||
const devices = deviceRepo.listDevices();
|
||||
const services = serviceRepo.listServices();
|
||||
const deviceById = new Map(devices.map((d) => [d.id, d]));
|
||||
|
||||
const deviceRows: TransferRow[] = devices.map((d) => ({
|
||||
type: "device",
|
||||
displayName: d.hostname,
|
||||
hostname: d.hostname,
|
||||
ip: d.ip,
|
||||
mac: d.mac ?? "",
|
||||
manufacturer: d.manufacturer ?? "",
|
||||
model: d.model ?? "",
|
||||
category: "",
|
||||
alias: "",
|
||||
favorite: "",
|
||||
visible: "",
|
||||
order: "",
|
||||
port: "",
|
||||
https: "",
|
||||
url: "",
|
||||
deviceHostname: "",
|
||||
deviceIp: "",
|
||||
}));
|
||||
|
||||
const serviceRows: TransferRow[] = services.map((s) => {
|
||||
const device = deviceById.get(s.deviceId);
|
||||
return {
|
||||
type: "service",
|
||||
displayName: s.displayName,
|
||||
hostname: s.hostname,
|
||||
ip: "",
|
||||
mac: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
category: s.category ?? "",
|
||||
alias: s.alias.join(";"),
|
||||
favorite: s.favorite ? "true" : "false",
|
||||
visible: s.visible ? "true" : "false",
|
||||
order: s.order,
|
||||
port: s.port,
|
||||
https: s.https ? "true" : "false",
|
||||
url: s.url,
|
||||
deviceHostname: device?.hostname ?? "",
|
||||
deviceIp: device?.ip ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
return [...deviceRows, ...serviceRows];
|
||||
}
|
||||
|
||||
export async function transferRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/transfer/export", async (request, reply) => {
|
||||
const query = request.query as { format?: string };
|
||||
const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv";
|
||||
const rows = buildExportRows();
|
||||
|
||||
if (format === "json") {
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.json"');
|
||||
reply.type("application/json");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||
|
||||
if (format === "xlsx") {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "LaunchPad");
|
||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.xlsx"');
|
||||
reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
return reply.send(buffer);
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.csv"');
|
||||
reply.type("text/csv; charset=utf-8");
|
||||
return reply.send(csv);
|
||||
});
|
||||
|
||||
app.post("/api/transfer/import", async (request, reply) => {
|
||||
const body = request.body as { format?: string; content?: string } | undefined;
|
||||
if (!body?.content) {
|
||||
return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" });
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
if (body.format === "json") {
|
||||
const text = Buffer.from(body.content, "base64").toString("utf-8");
|
||||
rows = JSON.parse(text);
|
||||
} else {
|
||||
const buffer = Buffer.from(body.content, "base64");
|
||||
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: "Datei konnte nicht gelesen werden",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let devicesImported = 0;
|
||||
let devicesSkipped = 0;
|
||||
let servicesImported = 0;
|
||||
let servicesSkipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Geräte-Zeilen zuerst verarbeiten, damit Dienst-Zeilen aus derselben
|
||||
// Datei sie bereits über deviceHostname/deviceIp finden können.
|
||||
const deviceRows = rows.filter((r) => String(r.type ?? "").trim() === "device");
|
||||
const serviceRows = rows.filter((r) => String(r.type ?? "").trim() === "service");
|
||||
|
||||
for (const row of deviceRows) {
|
||||
try {
|
||||
const hostname = String(row.hostname ?? row.displayName ?? "").trim();
|
||||
const ip = String(row.ip ?? "").trim();
|
||||
if (!hostname || !ip) {
|
||||
errors.push(`Geräte-Zeile übersprungen (hostname/ip fehlt): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
const existing =
|
||||
allDevices.find((d) => d.ip === ip) ?? allDevices.find((d) => d.hostname === hostname);
|
||||
|
||||
if (existing) {
|
||||
devicesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
deviceRepo.createDevice({
|
||||
hostname,
|
||||
ip,
|
||||
mac: row.mac ? String(row.mac).trim() : undefined,
|
||||
manufacturer: row.manufacturer ? String(row.manufacturer).trim() : undefined,
|
||||
model: row.model ? String(row.model).trim() : undefined,
|
||||
});
|
||||
devicesImported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of serviceRows) {
|
||||
try {
|
||||
const deviceHostname = String(row.deviceHostname ?? "").trim();
|
||||
const deviceIp = String(row.deviceIp ?? "").trim();
|
||||
const hostname = String(row.hostname ?? deviceHostname).trim();
|
||||
const port = Number(row.port);
|
||||
const displayName = String(row.displayName ?? "").trim();
|
||||
const url = String(row.url ?? "").trim();
|
||||
|
||||
if (!displayName || !hostname || !port || !url) {
|
||||
errors.push(`Dienst-Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined;
|
||||
if (!device && deviceHostname) {
|
||||
device = allDevices.find((d) => d.hostname === deviceHostname);
|
||||
}
|
||||
if (!device) {
|
||||
device = deviceRepo.createDevice({
|
||||
hostname: deviceHostname || hostname,
|
||||
ip: deviceIp || hostname,
|
||||
});
|
||||
devicesImported++;
|
||||
}
|
||||
|
||||
const existingService = serviceRepo
|
||||
.listServicesByDevice(device.id)
|
||||
.find((s) => s.port === port);
|
||||
if (existingService) {
|
||||
servicesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = row.category ? String(row.category).trim() : undefined;
|
||||
|
||||
serviceRepo.createService({
|
||||
deviceId: device.id,
|
||||
displayName,
|
||||
hostname,
|
||||
url,
|
||||
https: String(row.https ?? "").toLowerCase() === "true",
|
||||
port,
|
||||
category: category || undefined,
|
||||
alias: row.alias
|
||||
? String(row.alias)
|
||||
.split(";")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
favorite: String(row.favorite ?? "").toLowerCase() === "true",
|
||||
order: row.order !== undefined && row.order !== "" ? Number(row.order) : undefined,
|
||||
visible:
|
||||
row.visible !== undefined && row.visible !== ""
|
||||
? String(row.visible).toLowerCase() === "true"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (category) {
|
||||
categoryRepo.ensureCategory(category);
|
||||
}
|
||||
|
||||
servicesImported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
devicesImported,
|
||||
devicesSkipped,
|
||||
servicesImported,
|
||||
servicesSkipped,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface HttpProbeResult {
|
||||
status?: number;
|
||||
title?: string;
|
||||
faviconUrl?: string;
|
||||
description?: string;
|
||||
server?: string;
|
||||
bodySnippet?: string;
|
||||
}
|
||||
@@ -46,6 +47,7 @@ export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise<HttpProbeR
|
||||
status: res.statusCode,
|
||||
title: extractTitle(body),
|
||||
faviconUrl: extractFaviconUrl(body, baseUrl),
|
||||
description: extractDescription(body),
|
||||
server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader,
|
||||
bodySnippet: body,
|
||||
});
|
||||
@@ -82,3 +84,12 @@ function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractDescription(html: string): string | undefined {
|
||||
// <meta name="description" content="..."> in beiden Attribut-Reihenfolgen
|
||||
const match =
|
||||
html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ??
|
||||
html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
|
||||
const description = match?.[1]?.trim();
|
||||
return description ? description : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user