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 PORT=3001
ENV HOST=0.0.0.0 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/deploy/ ./
COPY --from=build /app/apps/backend/dist ./dist COPY --from=build /app/apps/backend/dist ./dist
COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/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, online INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL, source TEXT NOT NULL,
last_scan TEXT, last_scan TEXT,
last_ping TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "last_suggested_display_name", "TEXT"); ensureColumn("services", "last_suggested_display_name", "TEXT");
ensureColumn("services", "last_suggested_category", "TEXT"); ensureColumn("services", "last_suggested_category", "TEXT");
ensureColumn("devices", "last_ping", "TEXT");
} }
function ensureColumn(table: string, column: string, definition: string): void { 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, online: row.online,
source: row.source as Device["source"], source: row.source as Device["source"],
lastScan: row.lastScan, 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[] { export function listDevices(): Device[] {
return db.select().from(devices).all().map(mapRow); 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), online: integer("online", { mode: "boolean" }).notNull().default(false),
source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual
lastScan: text("last_scan"), // ISO-8601 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(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_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 { readLaterRoutes } from "./routes/readLater.js";
import { faviconProxyRoutes } from "./routes/faviconProxy.js"; import { faviconProxyRoutes } from "./routes/faviconProxy.js";
import { loadPlugins } from "./plugins/loader.js"; import { loadPlugins } from "./plugins/loader.js";
import { startLiveStatusHeartbeat } from "./liveStatus.js";
import * as serviceRepo from "./db/repositories/services.js"; import * as serviceRepo from "./db/repositories/services.js";
import * as bookmarkRepo from "./db/repositories/bookmarks.js"; import * as bookmarkRepo from "./db/repositories/bookmarks.js";
import * as categoryRepo from "./db/repositories/categories.js"; import * as categoryRepo from "./db/repositories/categories.js";
@@ -83,6 +84,7 @@ async function main() {
try { try {
await app.listen({ port: PORT, host: HOST }); await app.listen({ port: PORT, host: HOST });
app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`); app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`);
startLiveStatusHeartbeat();
} catch (err) { } catch (err) {
app.log.error(err); app.log.error(err);
process.exit(1); 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), recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
readLaterLimit: Number(all.readLaterLimit ?? 5), readLaterLimit: Number(all.readLaterLimit ?? 5),
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
liveStatusEnabled: all.liveStatusEnabled === "true",
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
}; };
}); });
app.patch("/api/settings", async (request, reply) => { app.patch("/api/settings", async (request, reply) => {
const body = request.body as const body = request.body as
| { recentVisitsLimit?: number; readLaterLimit?: number; staleDeviceThresholdDays?: number } | {
recentVisitsLimit?: number;
readLaterLimit?: number;
staleDeviceThresholdDays?: number;
liveStatusEnabled?: boolean;
liveStatusIntervalMinutes?: number;
}
| undefined; | undefined;
if (body?.recentVisitsLimit !== undefined) { if (body?.recentVisitsLimit !== undefined) {
@@ -40,11 +48,25 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
settingsRepo.setSetting("staleDeviceThresholdDays", String(Math.round(value))); 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(); const all = settingsRepo.listSettings();
return { return {
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
readLaterLimit: Number(all.readLaterLimit ?? 5), readLaterLimit: Number(all.readLaterLimit ?? 5),
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), 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 /<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
); );
const href = match?.[1]; 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 { try {
return new URL(href ?? "/favicon.ico", baseUrl).toString(); return new URL(href, baseUrl).toString();
} catch { } catch {
return undefined; 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 }; 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);
});
});
}

View File

@@ -4,6 +4,8 @@ export interface AppSettings {
recentVisitsLimit: number; recentVisitsLimit: number;
readLaterLimit: number; readLaterLimit: number;
staleDeviceThresholdDays: number; staleDeviceThresholdDays: number;
liveStatusEnabled: boolean;
liveStatusIntervalMinutes: number;
} }
async function fetchSettings(): Promise<AppSettings> { async function fetchSettings(): Promise<AppSettings> {

View File

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { rankServices, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js"; import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js"; import { useBookmarks } from "../hooks/useBookmarks.js";
@@ -54,12 +54,15 @@ async function saveReadLaterRequest(url: string) {
function ReadLaterBox() { function ReadLaterBox() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => saveReadLaterRequest(url.trim()), mutationFn: () => saveReadLaterRequest(url.trim()),
onSuccess: () => { onSuccess: () => {
setUrl(""); setUrl("");
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["read-later"] }); queryClient.invalidateQueries({ queryKey: ["read-later"] });
}, },
}); });
@@ -70,18 +73,42 @@ function ReadLaterBox() {
mutation.mutate(); mutation.mutate();
} }
if (!open) {
return (
<button
type="button"
onClick={() => {
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="flex items-center gap-1.5 text-xs text-black/40 transition-colors
hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
>
<FontAwesomeIcon icon={faPlus} className="text-[10px]" />
Link merken
</button>
);
}
return ( return (
<form onSubmit={handleSubmit} className="flex gap-2"> <form onSubmit={handleSubmit} className="flex w-full gap-2">
<input <input
ref={inputRef}
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setUrl("");
setOpen(false);
}
}}
placeholder="Link zum Später-Lesen hier einfügen …" 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 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-black/30 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: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"
/> />
<Button type="submit" variant="secondary" disabled={mutation.isPending || !url.trim()}> <Button type="submit" variant="secondary" size="sm" disabled={mutation.isPending || !url.trim()}>
{mutation.isPending ? "…" : "Merken"} {mutation.isPending ? "…" : "Merken"}
</Button> </Button>
</form> </form>
@@ -263,19 +290,19 @@ export function HomePage() {
</div> </div>
{/* Nicht-scrollender Kopfbereich: Titel, Favoriten, Suchfeld, Später-lesen, Zuletzt besucht */} {/* Nicht-scrollender Kopfbereich: Titel, Favoriten, Suchfeld, Später-lesen, Zuletzt besucht */}
<div className="flex shrink-0 flex-col items-center gap-6 px-6 pb-4 pt-8 sm:pt-12"> <div className="flex shrink-0 flex-col items-center gap-5 px-6 pb-4 pt-8 sm:pt-10">
<div className="flex flex-col items-center gap-1.5 text-center"> <div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-semibold tracking-tight text-black dark:text-white sm:text-3xl"> <h1 className="text-xl font-semibold tracking-tight text-black dark:text-white sm:text-2xl">
LaunchPad LaunchPad
</h1> </h1>
<p className="text-sm text-black/50 dark:text-white/50"> <p className="text-sm text-black/40 dark:text-white/40">
Tippe, um deine Homelab-Dienste sofort zu öffnen. Tippe, um deine Homelab-Dienste sofort zu öffnen.
</p> </p>
</div> </div>
<div ref={searchContainerRef} className="w-full max-w-xl"> <div ref={searchContainerRef} className="w-full max-w-xl">
{hasFavorites ? ( {hasFavorites ? (
<div className="mb-5 flex flex-col gap-4"> <div className="mb-4 flex flex-col gap-3 divide-y divide-black/5 dark:divide-white/5">
{favoriteServices.length > 0 ? ( {favoriteServices.length > 0 ? (
<FavoritesBar <FavoritesBar
items={favoriteServices} items={favoriteServices}
@@ -289,16 +316,18 @@ export function HomePage() {
/> />
) : null} ) : null}
{favoriteBookmarks.length > 0 ? ( {favoriteBookmarks.length > 0 ? (
<FavoritesBar <div className={favoriteServices.length > 0 ? "pt-3" : ""}>
items={favoriteBookmarks} <FavoritesBar
label="Lesezeichen" items={favoriteBookmarks}
categoryColors={categoryColors} label="Lesezeichen"
onOpen={(item) => { categoryColors={categoryColors}
const bookmark = favoriteBookmarks.find((b) => b.id === item.id); onOpen={(item) => {
if (bookmark) openItem({ ...bookmark, kind: "bookmark" }); const bookmark = favoriteBookmarks.find((b) => b.id === item.id);
}} if (bookmark) openItem({ ...bookmark, kind: "bookmark" });
onReorder={(ids) => reorderBookmarks.mutate(ids)} }}
/> onReorder={(ids) => reorderBookmarks.mutate(ids)}
/>
</div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -318,41 +347,58 @@ export function HomePage() {
/> />
{!isSearching || !resultsVisible ? ( {!isSearching || !resultsVisible ? (
<div className="mt-3 flex flex-col gap-3"> <div
<ReadLaterBox /> className="mt-4 divide-y divide-black/5 rounded-2xl border border-black/5
bg-black/[0.015] dark:divide-white/5 dark:border-white/5 dark:bg-white/[0.02]"
>
{(() => { {(() => {
const readLaterLimit = settings?.readLaterLimit ?? 5; const readLaterLimit = settings?.readLaterLimit ?? 5;
if (!readLaterItems || readLaterItems.length === 0 || readLaterLimit <= 0) return null; const hasReadLaterChips =
readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0;
return ( return (
<div className="flex flex-wrap items-center justify-center gap-2"> <div className="flex flex-col gap-2 px-4 py-3">
{readLaterItems.slice(0, readLaterLimit).map((item) => ( <div className="flex items-center justify-between gap-2">
<button <span className="text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
key={item.id} Später lesen
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")} </span>
title={item.displayName} <ReadLaterBox />
className="flex items-center gap-1.5 rounded-full border border-black/10 </div>
bg-white/50 px-2.5 py-1 text-xs text-black/60 hover:bg-black/5 {hasReadLaterChips ? (
dark:border-white/10 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10" <div className="flex flex-wrap items-center gap-2">
> {readLaterItems!.slice(0, readLaterLimit).map((item) => (
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" /> <button
<span className="max-w-[8rem] truncate">{item.displayName}</span> key={item.id}
</button> onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
))} title={item.displayName}
className="flex items-center gap-1.5 rounded-full border border-black/10
bg-white/60 px-2.5 py-1 text-xs text-black/60 transition-colors
hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white/60
dark:hover:bg-white/10"
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<span className="max-w-[8rem] truncate">{item.displayName}</span>
</button>
))}
</div>
) : null}
</div> </div>
); );
})()} })()}
{recentVisits && recentVisits.length > 0 ? ( {recentVisits && recentVisits.length > 0 ? (
<FavoritesBar <div className="px-4 py-3">
items={recentVisits} <span className="mb-2 block text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
label="Zuletzt besucht" Zuletzt besucht
categoryColors={categoryColors} </span>
onOpen={(item) => { <FavoritesBar
const original = recentVisits.find((r) => r.id === item.id); items={recentVisits}
if (original) openItem(original); categoryColors={categoryColors}
}} onOpen={(item) => {
/> const original = recentVisits.find((r) => r.id === item.id);
if (original) openItem(original);
}}
/>
</div>
) : null} ) : null}
</div> </div>
) : null} ) : null}

View File

@@ -7,6 +7,7 @@ import { Button, Favicon } from "@launchpad/ui";
import type { Bookmark } from "@launchpad/shared"; import type { Bookmark } from "@launchpad/shared";
import { suggestBookmarkCategory } from "@launchpad/shared"; import { suggestBookmarkCategory } from "@launchpad/shared";
import { useBookmarks } from "../../hooks/useBookmarks.js"; import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useServices } from "../../hooks/useServices.js";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
@@ -205,6 +206,8 @@ const EDIT_FORM_COLSPAN = 7;
function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) { function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: allBookmarks } = useBookmarks();
const { data: allServices } = useServices();
const [displayName, setDisplayName] = useState(bookmark.displayName); const [displayName, setDisplayName] = useState(bookmark.displayName);
const [url, setUrl] = useState(bookmark.url); const [url, setUrl] = useState(bookmark.url);
const [category, setCategory] = useState(bookmark.category ?? ""); const [category, setCategory] = useState(bookmark.category ?? "");
@@ -213,8 +216,20 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
const [favicon, setFavicon] = useState<string | null | undefined>(undefined); const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
const [faviconError, setFaviconError] = useState<string | null>(null); const [faviconError, setFaviconError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const FAVICON_MAX_BYTES = 300 * 1024; const FAVICON_MAX_BYTES = 300 * 1024;
const existingFavicons = useMemo(() => {
const seen = new Map<string, string>();
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) { function handleFaviconFile(file: File | undefined) {
setFaviconError(null); setFaviconError(null);
if (!file) return; if (!file) return;
@@ -268,6 +283,16 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
onChange={(e) => handleFaviconFile(e.target.files?.[0])} onChange={(e) => handleFaviconFile(e.target.files?.[0])}
/> />
</label> </label>
{existingFavicons.length > 0 ? (
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="rounded-lg border border-black/10 px-2 py-1 text-xs text-black/70
hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10"
>
Vorhandenes wählen
</button>
) : null}
{faviconPreview ? ( {faviconPreview ? (
<button <button
type="button" type="button"
@@ -279,6 +304,25 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
) : null} ) : null}
</div> </div>
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null} {faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
{pickerOpen ? (
<div className="mt-2 flex max-w-xs flex-wrap gap-1.5 rounded-lg border border-black/10 p-2
dark:border-white/10">
{existingFavicons.map(([iconUrl, name]) => (
<button
key={iconUrl}
type="button"
title={name}
onClick={() => {
setFavicon(iconUrl);
setPickerOpen(false);
}}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={iconUrl} fallbackLetter={name} size="sm" />
</button>
))}
</div>
) : null}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>

View File

@@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared"; import type { Service } from "@launchpad/shared";
import { useServices } from "../../hooks/useServices.js"; import { useServices } from "../../hooks/useServices.js";
import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
@@ -110,6 +111,8 @@ const EDIT_FORM_COLSPAN = 12;
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: allServices } = useServices();
const { data: allBookmarks } = useBookmarks();
const [displayName, setDisplayName] = useState(service.displayName); const [displayName, setDisplayName] = useState(service.displayName);
const [category, setCategory] = useState(service.category ?? ""); const [category, setCategory] = useState(service.category ?? "");
const [alias, setAlias] = useState(service.alias.join(", ")); 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. // string = neu hochgeladenes Favicon als data:-URL.
const [favicon, setFavicon] = useState<string | null | undefined>(undefined); const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
const [faviconError, setFaviconError] = useState<string | null>(null); const [faviconError, setFaviconError] = useState<string | null>(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<string, string>(); // 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; 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])} onChange={(e) => handleFaviconFile(e.target.files?.[0])}
/> />
</label> </label>
{existingFavicons.length > 0 ? (
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="rounded-lg border border-black/10 px-2 py-1 text-xs text-black/70
hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10"
>
Vorhandenes wählen
</button>
) : null}
{faviconPreview ? ( {faviconPreview ? (
<button <button
type="button" type="button"
@@ -203,6 +232,25 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
) : null} ) : null}
</div> </div>
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null} {faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
{pickerOpen ? (
<div className="mt-2 flex max-w-xs flex-wrap gap-1.5 rounded-lg border border-black/10 p-2
dark:border-white/10">
{existingFavicons.map(([iconUrl, name]) => (
<button
key={iconUrl}
type="button"
title={name}
onClick={() => {
setFavicon(iconUrl);
setPickerOpen(false);
}}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={iconUrl} fallbackLetter={name} size="sm" />
</button>
))}
</div>
) : null}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>

View File

@@ -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 (
<div className="flex items-center justify-between py-1">
<span className="text-sm text-black/50 dark:text-white/50">Aktiviert</span>
<button
onClick={() => mutation.mutate(!enabled)}
disabled={mutation.isPending}
role="switch"
aria-checked={enabled}
className={`relative h-6 w-11 rounded-full transition-colors ${
enabled ? "bg-indigo-500" : "bg-black/15 dark:bg-white/15"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
</div>
);
}
function LimitSetting({ function LimitSetting({
label, label,
hint, hint,
@@ -26,7 +64,7 @@ function LimitSetting({
}: { }: {
label: string; label: string;
hint: string; hint: string;
settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays"; settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays" | "liveStatusIntervalMinutes";
value: number | undefined; value: number | undefined;
max?: number; max?: number;
}) { }) {
@@ -338,6 +376,26 @@ export function SettingsPage() {
/> />
</div> </div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-1 font-medium text-black dark:text-white">Live-Status</h2>
<p className="mb-2 text-xs text-black/40 dark:text-white/40">
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.
</p>
<LiveStatusToggle enabled={settings?.liveStatusEnabled ?? false} />
{settings?.liveStatusEnabled ? (
<LimitSetting
label="Prüfintervall (Minuten)"
hint="Wie oft alle Geräte angepingt werden."
settingKey="liveStatusIntervalMinutes"
value={settings?.liveStatusIntervalMinutes}
max={1440}
/>
) : null}
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10"> <div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">HTTPS</h2> <h2 className="mb-2 font-medium text-black dark:text-white">HTTPS</h2>
<p className="mb-3 text-sm text-black/50 dark:text-white/50"> <p className="mb-3 text-sm text-black/50 dark:text-white/50">

View File

@@ -20,6 +20,8 @@ export interface Device {
online: boolean; online: boolean;
source: DeviceSource; source: DeviceSource;
lastScan: string | null; // ISO-8601 Zeitstempel 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]; export type DeviceSource = (typeof deviceSourceValues)[number];