round19: Startseite aufgeraeumt, Favicon-Fallback ueber Ports, Favicon-Picker, Live-Status per Ping

This commit is contained in:
2026-07-23 18:10:03 +02:00
parent bae9e48fc1
commit ec49534b60
16 changed files with 393 additions and 51 deletions

View File

@@ -32,6 +32,11 @@ ENV DATABASE_PATH=/data/launchpad.db
ENV PORT=3001
ENV HOST=0.0.0.0
# iputils-ping stellt einen "echten" ping-Befehl mit ICMP-Unterstützung
# bereit (nicht nur die BusyBox-Variante) - für den optionalen Live-Status-
# Heartbeat (siehe src/liveStatus.ts, standardmäßig deaktiviert).
RUN apk add --no-cache iputils-ping
COPY --from=build /app/deploy/ ./
COPY --from=build /app/apps/backend/dist ./dist
COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/dist

View File

@@ -36,6 +36,7 @@ export function ensureSchema(): void {
online INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
last_scan TEXT,
last_ping TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -134,6 +135,7 @@ export function ensureSchema(): void {
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "last_suggested_display_name", "TEXT");
ensureColumn("services", "last_suggested_category", "TEXT");
ensureColumn("devices", "last_ping", "TEXT");
}
function ensureColumn(table: string, column: string, definition: string): void {

View File

@@ -20,9 +20,23 @@ function mapRow(row: typeof devices.$inferSelect): Device {
online: row.online,
source: row.source as Device["source"],
lastScan: row.lastScan,
lastPing: row.lastPing,
};
}
/**
* Aktualisiert NUR online-Status + lastPing (Zeitstempel des Live-Status-
* Checks) - bewusst getrennt von jedem "echten" Scan-Upsert. Wird
* ausschließlich vom optionalen Ping-Heartbeat genutzt (siehe
* liveStatus.ts), niemals von einem Scan-Endpunkt.
*/
export function updateDeviceOnlineStatus(id: string, online: boolean): void {
db.update(devices)
.set({ online, lastPing: nowIso() })
.where(eq(devices.id, id))
.run();
}
export function listDevices(): Device[] {
return db.select().from(devices).all().map(mapRow);
}

View File

@@ -13,6 +13,11 @@ export const devices = sqliteTable("devices", {
online: integer("online", { mode: "boolean" }).notNull().default(false),
source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual
lastScan: text("last_scan"), // ISO-8601
// Zeitpunkt des letzten Ping-Live-Status-Checks (siehe liveStatus.ts) -
// bewusst getrennt von lastScan, das an echte Scan-Funde gebunden ist und
// die 7-Tage-"nicht mehr gemeldet"-Schwelle beim FritzBox-Scan speist. Ein
// Ping ändert daran nichts, ist nur ein Erreichbarkeits-Heartbeat.
lastPing: text("last_ping"), // ISO-8601
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});

View File

@@ -17,6 +17,7 @@ import { settingsRoutes } from "./routes/settings.js";
import { readLaterRoutes } from "./routes/readLater.js";
import { faviconProxyRoutes } from "./routes/faviconProxy.js";
import { loadPlugins } from "./plugins/loader.js";
import { startLiveStatusHeartbeat } from "./liveStatus.js";
import * as serviceRepo from "./db/repositories/services.js";
import * as bookmarkRepo from "./db/repositories/bookmarks.js";
import * as categoryRepo from "./db/repositories/categories.js";
@@ -83,6 +84,7 @@ async function main() {
try {
await app.listen({ port: PORT, host: HOST });
app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`);
startLiveStatusHeartbeat();
} catch (err) {
app.log.error(err);
process.exit(1);

View File

@@ -0,0 +1,51 @@
import * as deviceRepo from "./db/repositories/devices.js";
import * as settingsRepo from "./db/repositories/settings.js";
import { pingHost } from "./scanner/ping.js";
const CHECK_INTERVAL_MS = 60 * 1000;
let lastRunAt = 0;
let running = false;
/**
* Optionaler Live-Status-Heartbeat: pingt (ICMP, siehe scanner/ping.ts) alle
* bekannten Geräte in konfigurierbarem Abstand an und aktualisiert NUR deren
* online-Feld + lastPing-Zeitstempel.
*
* Bewusst STRIKT getrennt vom "Scans laufen ausschließlich manuell"-Prinzip
* (siehe Scanner-Seite): hier werden NIE Dienste neu angelegt, NIE Namen/
* Kategorien vorgeschlagen, NIE etwas zur Bestätigung vorgelegt - einzig der
* Online-Punkt neben einem Gerät wird aktualisiert. Standardmäßig
* AUSGESCHALTET (liveStatusEnabled=false), muss in den Einstellungen bewusst
* aktiviert werden.
*
* Statt eines echten setInterval mit dynamischer Laufzeit wird jede Minute
* geprüft, ob genug Zeit seit dem letzten Lauf vergangen ist - so greift eine
* Änderung des Intervalls in den Einstellungen beim nächsten Tick sofort,
* ohne den Timer neu aufsetzen zu müssen.
*/
export function startLiveStatusHeartbeat(): void {
setInterval(async () => {
if (running) return; // vorheriger Lauf noch nicht fertig - überspringen statt zu stapeln
const settings = settingsRepo.listSettings();
if (settings.liveStatusEnabled !== "true") return;
const intervalMinutes = Number(settings.liveStatusIntervalMinutes ?? 5);
const intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
if (Date.now() - lastRunAt < intervalMs) return;
running = true;
lastRunAt = Date.now();
try {
const devices = deviceRepo.listDevices();
// Nacheinander statt Promise.all, um das Netzwerk (und ggf. langsame
// Router/IoT-Geräte) nicht mit vielen gleichzeitigen Pings zu fluten.
for (const device of devices) {
const online = await pingHost(device.ip);
deviceRepo.updateDeviceOnlineStatus(device.id, online);
}
} finally {
running = false;
}
}, CHECK_INTERVAL_MS);
}

View File

@@ -8,12 +8,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
readLaterLimit: Number(all.readLaterLimit ?? 5),
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
liveStatusEnabled: all.liveStatusEnabled === "true",
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
};
});
app.patch("/api/settings", async (request, reply) => {
const body = request.body as
| { recentVisitsLimit?: number; readLaterLimit?: number; staleDeviceThresholdDays?: number }
| {
recentVisitsLimit?: number;
readLaterLimit?: number;
staleDeviceThresholdDays?: number;
liveStatusEnabled?: boolean;
liveStatusIntervalMinutes?: number;
}
| undefined;
if (body?.recentVisitsLimit !== undefined) {
@@ -40,11 +48,25 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
settingsRepo.setSetting("staleDeviceThresholdDays", String(Math.round(value)));
}
if (body?.liveStatusEnabled !== undefined) {
settingsRepo.setSetting("liveStatusEnabled", body.liveStatusEnabled ? "true" : "false");
}
if (body?.liveStatusIntervalMinutes !== undefined) {
const value = Number(body.liveStatusIntervalMinutes);
if (!Number.isFinite(value) || value < 1 || value > 1440) {
return reply.code(400).send({ error: "liveStatusIntervalMinutes muss zwischen 1 und 1440 liegen" });
}
settingsRepo.setSetting("liveStatusIntervalMinutes", String(Math.round(value)));
}
const all = settingsRepo.listSettings();
return {
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
readLaterLimit: Number(all.readLaterLimit ?? 5),
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
liveStatusEnabled: all.liveStatusEnabled === "true",
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
};
});
}

View File

@@ -77,9 +77,15 @@ function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
);
const href = match?.[1];
// Kein <link rel="icon"> im HTML gefunden -> hier NICHT mehr blind auf
// "/favicon.ico" raten (das täuscht ein bestätigtes Favicon vor, das oft
// gar nicht existiert - 404). Der Aufrufer (scanDeviceServices) entscheidet
// die Fallback-Reihenfolge: erst ein Favicon eines anderen Dienstes auf
// demselben Gerät übernehmen, erst danach selbst raten.
if (!href) return undefined;
try {
return new URL(href ?? "/favicon.ico", baseUrl).toString();
return new URL(href, baseUrl).toString();
} catch {
return undefined;
}

View File

@@ -136,5 +136,17 @@ export async function scanDeviceServices(
});
}
// Fallback-Reihenfolge für Dienste ohne bestätigtes Favicon (kein
// <link rel="icon"> im HTML gefunden): zuerst das Favicon eines ANDEREN
// Dienstes auf demselben Gerät übernehmen (typischerweise dieselbe
// Software/dasselbe Gerät, das Icon passt meist trotzdem) - erst wenn auch
// das fehlt, wird als letzter Ausweg "/favicon.ico" am eigenen Port
// geraten (kann ins Leere laufen, ist aber besser als gar kein Versuch).
const firstSiblingFavicon = found.find((s) => s.favicon)?.favicon;
for (const service of found) {
if (service.favicon) continue;
service.favicon = firstSiblingFavicon ?? `${service.url}/favicon.ico`;
}
return { services: found, suggestedHostname };
}

View File

@@ -0,0 +1,23 @@
import { exec } from "node:child_process";
import { platform } from "node:os";
/**
* Prüft per System-Ping (ICMP), ob eine IP erreichbar ist. Bewusst NICHT über
* einen Port-Connect (wie isPortOpen in ports.ts) - ein Gerät kann online
* sein, ohne einen der bekannten Ports offen zu haben (z. B. ein Drucker
* ohne Web-UI), ein echter Ping ist hier das treffendere Signal.
*
* Nutzt den System-ping-Befehl statt eines rohen ICMP-Sockets, da Node dafür
* root-Rechte bräuchte - für einen einzelnen Zwischencheck reicht das völlig.
* Timeout: 1 Sekunde, ein einzelnes Paket.
*/
export function pingHost(ip: string): Promise<boolean> {
const isWindows = platform() === "win32";
const command = isWindows ? `ping -n 1 -w 1000 ${ip}` : `ping -c 1 -W 1 ${ip}`;
return new Promise((resolve) => {
exec(command, { timeout: 2000 }, (error) => {
resolve(!error);
});
});
}