generated from Dicken/dickendock
round19: Startseite aufgeraeumt, Favicon-Fallback ueber Ports, Favicon-Picker, Live-Status per Ping
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
51
apps/backend/src/liveStatus.ts
Normal file
51
apps/backend/src/liveStatus.ts
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
23
apps/backend/src/scanner/ping.ts
Normal file
23
apps/backend/src/scanner/ping.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user