diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index df238eb..d76ef71 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -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 diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 92f1f36..d94487e 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -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 { diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 965c1dc..0d4149c 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -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); } diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 4875218..47be10b 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -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(), }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 79633aa..ac7d182 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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); diff --git a/apps/backend/src/liveStatus.ts b/apps/backend/src/liveStatus.ts new file mode 100644 index 0000000..ba8bd31 --- /dev/null +++ b/apps/backend/src/liveStatus.ts @@ -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); +} diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index a4c9a8f..1d7ae9f 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -8,12 +8,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise { 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 { 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), }; }); } diff --git a/apps/backend/src/scanner/http.ts b/apps/backend/src/scanner/http.ts index b14e73d..24caaca 100644 --- a/apps/backend/src/scanner/http.ts +++ b/apps/backend/src/scanner/http.ts @@ -77,9 +77,15 @@ function extractFaviconUrl(html: string, baseUrl: string): string | undefined { /]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i ); const href = match?.[1]; + // Kein 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; } diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index f24b888..af7638a 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -136,5 +136,17 @@ export async function scanDeviceServices( }); } + // Fallback-Reihenfolge für Dienste ohne bestätigtes Favicon (kein + // 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 }; } diff --git a/apps/backend/src/scanner/ping.ts b/apps/backend/src/scanner/ping.ts new file mode 100644 index 0000000..f4471af --- /dev/null +++ b/apps/backend/src/scanner/ping.ts @@ -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 { + 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); + }); + }); +} diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts index b4ccc63..84be5b8 100644 --- a/apps/frontend/src/hooks/useSettings.ts +++ b/apps/frontend/src/hooks/useSettings.ts @@ -4,6 +4,8 @@ export interface AppSettings { recentVisitsLimit: number; readLaterLimit: number; staleDeviceThresholdDays: number; + liveStatusEnabled: boolean; + liveStatusIntervalMinutes: number; } async function fetchSettings(): Promise { diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index cf0b9cd..7c20326 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faGear, faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; +import { faGear, faSun, faMoon, faPlus } from "@fortawesome/free-solid-svg-icons"; import { rankServices, type SearchResult } from "@launchpad/shared"; import { useServices } from "../hooks/useServices.js"; import { useBookmarks } from "../hooks/useBookmarks.js"; @@ -54,12 +54,15 @@ async function saveReadLaterRequest(url: string) { function ReadLaterBox() { const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); const [url, setUrl] = useState(""); + const inputRef = useRef(null); const mutation = useMutation({ mutationFn: () => saveReadLaterRequest(url.trim()), onSuccess: () => { setUrl(""); + setOpen(false); queryClient.invalidateQueries({ queryKey: ["read-later"] }); }, }); @@ -70,18 +73,42 @@ function ReadLaterBox() { mutation.mutate(); } + if (!open) { + return ( + + ); + } + return ( -
+ setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + setUrl(""); + setOpen(false); + } + }} placeholder="Link zum Später-Lesen hier einfügen …" - className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm - text-black outline-none placeholder:text-black/30 focus:border-black/30 + className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-1.5 text-sm + text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30 - dark:focus:border-white/30" + dark:focus:border-indigo-400/40" /> -
@@ -263,19 +290,19 @@ export function HomePage() { {/* Nicht-scrollender Kopfbereich: Titel, Favoriten, Suchfeld, Später-lesen, Zuletzt besucht */} -
-
-

+
+
+

LaunchPad

-

+

Tippe, um deine Homelab-Dienste sofort zu öffnen.

{hasFavorites ? ( -
+
{favoriteServices.length > 0 ? ( ) : null} {favoriteBookmarks.length > 0 ? ( - { - const bookmark = favoriteBookmarks.find((b) => b.id === item.id); - if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); - }} - onReorder={(ids) => reorderBookmarks.mutate(ids)} - /> +
0 ? "pt-3" : ""}> + { + const bookmark = favoriteBookmarks.find((b) => b.id === item.id); + if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); + }} + onReorder={(ids) => reorderBookmarks.mutate(ids)} + /> +
) : null}
) : null} @@ -318,41 +347,58 @@ export function HomePage() { /> {!isSearching || !resultsVisible ? ( -
- - +
{(() => { const readLaterLimit = settings?.readLaterLimit ?? 5; - if (!readLaterItems || readLaterItems.length === 0 || readLaterLimit <= 0) return null; + const hasReadLaterChips = + readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0; return ( -
- {readLaterItems.slice(0, readLaterLimit).map((item) => ( - - ))} +
+
+ + Später lesen + + +
+ {hasReadLaterChips ? ( +
+ {readLaterItems!.slice(0, readLaterLimit).map((item) => ( + + ))} +
+ ) : null}
); })()} {recentVisits && recentVisits.length > 0 ? ( - { - const original = recentVisits.find((r) => r.id === item.id); - if (original) openItem(original); - }} - /> +
+ + Zuletzt besucht + + { + const original = recentVisits.find((r) => r.id === item.id); + if (original) openItem(original); + }} + /> +
) : null}
) : null} diff --git a/apps/frontend/src/routes/admin/BookmarksPage.tsx b/apps/frontend/src/routes/admin/BookmarksPage.tsx index 5d885b2..3b4d80b 100644 --- a/apps/frontend/src/routes/admin/BookmarksPage.tsx +++ b/apps/frontend/src/routes/admin/BookmarksPage.tsx @@ -7,6 +7,7 @@ import { Button, Favicon } from "@launchpad/ui"; import type { Bookmark } from "@launchpad/shared"; import { suggestBookmarkCategory } from "@launchpad/shared"; import { useBookmarks } from "../../hooks/useBookmarks.js"; +import { useServices } from "../../hooks/useServices.js"; import { useCategories } from "../../hooks/useCategories.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; @@ -205,6 +206,8 @@ const EDIT_FORM_COLSPAN = 7; function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) { const queryClient = useQueryClient(); + const { data: allBookmarks } = useBookmarks(); + const { data: allServices } = useServices(); const [displayName, setDisplayName] = useState(bookmark.displayName); const [url, setUrl] = useState(bookmark.url); const [category, setCategory] = useState(bookmark.category ?? ""); @@ -213,8 +216,20 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void const [favicon, setFavicon] = useState(undefined); const [faviconError, setFaviconError] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); const FAVICON_MAX_BYTES = 300 * 1024; + const existingFavicons = useMemo(() => { + const seen = new Map(); + for (const b of allBookmarks ?? []) { + if (b.favicon && b.id !== bookmark.id && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName); + } + for (const s of allServices ?? []) { + if (s.favicon && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName); + } + return Array.from(seen.entries()); + }, [allBookmarks, allServices, bookmark.id]); + function handleFaviconFile(file: File | undefined) { setFaviconError(null); if (!file) return; @@ -268,6 +283,16 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void onChange={(e) => handleFaviconFile(e.target.files?.[0])} /> + {existingFavicons.length > 0 ? ( + + ) : null} {faviconPreview ? (
{faviconError ?

{faviconError}

: null} + {pickerOpen ? ( +
+ {existingFavicons.map(([iconUrl, name]) => ( + + ))} +
+ ) : null}
diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 50dd599..430e7b6 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; import { useServices } from "../../hooks/useServices.js"; +import { useBookmarks } from "../../hooks/useBookmarks.js"; import { useCategories } from "../../hooks/useCategories.js"; import { useDevices } from "../../hooks/useDevices.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; @@ -110,6 +111,8 @@ const EDIT_FORM_COLSPAN = 12; function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { const queryClient = useQueryClient(); + const { data: allServices } = useServices(); + const { data: allBookmarks } = useBookmarks(); const [displayName, setDisplayName] = useState(service.displayName); const [category, setCategory] = useState(service.category ?? ""); const [alias, setAlias] = useState(service.alias.join(", ")); @@ -122,6 +125,22 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) // string = neu hochgeladenes Favicon als data:-URL. const [favicon, setFavicon] = useState(undefined); const [faviconError, setFaviconError] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + + // Bereits verwendete Favicons (Dienste + Lesezeichen), zur Auswahl im + // "Vorhandenes Favicon wählen"-Picker - z. B. wenn mehrere Dienste + // desselben Geräts eigentlich dasselbe Icon zeigen sollten, der Scan aber + // nur bei einem davon eins gefunden hat. + const existingFavicons = useMemo(() => { + const seen = new Map(); // favicon-URL -> Anzeigename für Tooltip + for (const s of allServices ?? []) { + if (s.favicon && s.id !== service.id && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName); + } + for (const b of allBookmarks ?? []) { + if (b.favicon && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName); + } + return Array.from(seen.entries()); + }, [allServices, allBookmarks, service.id]); const FAVICON_MAX_BYTES = 300 * 1024; @@ -192,6 +211,16 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) onChange={(e) => handleFaviconFile(e.target.files?.[0])} /> + {existingFavicons.length > 0 ? ( + + ) : null} {faviconPreview ? (
{faviconError ?

{faviconError}

: null} + {pickerOpen ? ( +
+ {existingFavicons.map(([iconUrl, name]) => ( + + ))} +
+ ) : null}
diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index 12bc648..fa0eec5 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -17,6 +17,44 @@ function InfoRow({ label, value }: { label: string; value: string }) { ); } +function LiveStatusToggle({ enabled }: { enabled: boolean }) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (next: boolean) => { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ liveStatusEnabled: next }), + }); + if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`); + return res.json(); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), + }); + + return ( +
+ Aktiviert + +
+ ); +} + function LimitSetting({ label, hint, @@ -26,7 +64,7 @@ function LimitSetting({ }: { label: string; hint: string; - settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays"; + settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays" | "liveStatusIntervalMinutes"; value: number | undefined; max?: number; }) { @@ -338,6 +376,26 @@ export function SettingsPage() { />
+
+

Live-Status

+

+ Prüft in regelmäßigem Abstand per Ping, ob ein Gerät erreichbar ist, und + aktualisiert nur dessen Online-Punkt. Legt nie neue Dienste an und schlägt nie + Namen/Kategorien vor – das bleibt weiterhin ausschließlich manuellen Scans + vorbehalten. Standardmäßig aus. +

+ + {settings?.liveStatusEnabled ? ( + + ) : null} +
+

HTTPS

diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f862133..6462e46 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,6 +20,8 @@ export interface Device { online: boolean; source: DeviceSource; lastScan: string | null; // ISO-8601 Zeitstempel + /** Zeitpunkt des letzten Ping-Live-Status-Checks, falls aktiviert (siehe Einstellungen). */ + lastPing: string | null; } export type DeviceSource = (typeof deviceSourceValues)[number];