round16: Geraete-Erkennung per ID statt IP fixen, Schwelle konfigurierbar, Icons auf Font Awesome umgestellt

This commit is contained in:
2026-07-23 13:23:20 +02:00
parent 7035d4ebda
commit 050616a5cf
11 changed files with 101 additions and 54 deletions

View File

@@ -3,6 +3,7 @@ import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js"; import * as serviceRepo from "../db/repositories/services.js";
import * as categoryRepo from "../db/repositories/categories.js"; import * as categoryRepo from "../db/repositories/categories.js";
import * as logRepo from "../db/repositories/logs.js"; import * as logRepo from "../db/repositories/logs.js";
import * as settingsRepo from "../db/repositories/settings.js";
import { scanDeviceServices } from "../scanner/networkScanner.js"; import { scanDeviceServices } from "../scanner/networkScanner.js";
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
@@ -208,20 +209,29 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
); );
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
// mehr in der Liste sind werden erst nach mindestens 7 Tagen // mehr in der Liste sind werden erst nach mindestens
// Abwesenheit zur Durchsicht vorgeschlagen (nicht schon beim ersten // staleDeviceThresholdDays Tagen Abwesenheit zur Durchsicht
// verpassten Scan), damit ein kurzzeitig offline/im Standby befindliches // vorgeschlagen (nicht schon beim ersten verpassten Scan), damit ein
// Gerät (Reboot, WLAN-Aussetzer, Nacht-Standby) nicht sofort zum // kurzzeitig offline/im Standby befindliches Gerät (Reboot,
// Löschen vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert // WLAN-Aussetzer, Nacht-Standby) nicht sofort zum Löschen
// (siehe upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither // vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert (siehe
// nicht mehr da. // upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither nicht
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; // mehr da.
//
// "Gefunden" wird über die ID der in DIESEM Scan tatsächlich
// upgeserteten Geräte geprüft (nicht über einen IP-Abgleich!): ändert
// sich die IP eines Geräts (z. B. DHCP-Neuvergabe), wird es trotzdem
// korrekt über seine MAC wiedererkannt und upgeserted - ein reiner
// IP-Vergleich hätte es fälschlich als "nicht mehr gefunden" gemeldet,
// obwohl es im selben Scan noch da war, nur unter neuer IP.
const foundDeviceIds = new Set(devices.map((d) => d.id));
const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000;
const now = Date.now(); const now = Date.now();
const foundIps = new Set(hosts.map((h) => h.ip));
const staleDevices = devicesBeforeScan.filter((d) => { const staleDevices = devicesBeforeScan.filter((d) => {
if (foundIps.has(d.ip)) return false; if (foundDeviceIds.has(d.id)) return false;
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0; const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
return now - lastSeen >= SEVEN_DAYS_MS; return now - lastSeen >= staleThresholdMs;
}); });
logRepo.logScan({ logRepo.logScan({

View File

@@ -7,12 +7,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
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),
}; };
}); });
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 } | { recentVisitsLimit?: number; readLaterLimit?: number; staleDeviceThresholdDays?: number }
| undefined; | undefined;
if (body?.recentVisitsLimit !== undefined) { if (body?.recentVisitsLimit !== undefined) {
@@ -31,10 +32,19 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
settingsRepo.setSetting("readLaterLimit", String(Math.round(value))); settingsRepo.setSetting("readLaterLimit", String(Math.round(value)));
} }
if (body?.staleDeviceThresholdDays !== undefined) {
const value = Number(body.staleDeviceThresholdDays);
if (!Number.isFinite(value) || value < 0 || value > 90) {
return reply.code(400).send({ error: "staleDeviceThresholdDays muss zwischen 0 und 90 liegen" });
}
settingsRepo.setSetting("staleDeviceThresholdDays", 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),
}; };
}); });
} }

View File

@@ -10,6 +10,10 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@launchpad/shared": "workspace:*", "@launchpad/shared": "workspace:*",
"@launchpad/ui": "workspace:*", "@launchpad/ui": "workspace:*",
"@tanstack/react-query": "^5.51.23", "@tanstack/react-query": "^5.51.23",

View File

@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
export interface AppSettings { export interface AppSettings {
recentVisitsLimit: number; recentVisitsLimit: number;
readLaterLimit: number; readLaterLimit: number;
staleDeviceThresholdDays: number;
} }
async function fetchSettings(): Promise<AppSettings> { async function fetchSettings(): Promise<AppSettings> {

View File

@@ -1,4 +1,6 @@
import { useMemo, useState, type DragEvent, type FormEvent } from "react"; import { useMemo, useState, type DragEvent, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Bookmark } from "@launchpad/shared"; import type { Bookmark } from "@launchpad/shared";
@@ -260,8 +262,8 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> <Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"></Button> <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
{mutation.isError ? ( {mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span> <span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null} ) : null}
@@ -354,8 +356,8 @@ function BookmarkRow({
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"></Button> <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑</Button> <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -1,4 +1,6 @@
import { useState, type DragEvent, type FormEvent } from "react"; import { useState, type DragEvent, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Category } from "@launchpad/shared"; import type { Category } from "@launchpad/shared";
@@ -125,18 +127,14 @@ function CategoryRow({
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white" dark:border-white/10 dark:bg-white/10 dark:text-white"
/> />
<Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> <Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen"> <Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
</Button>
</> </>
) : ( ) : (
<> <>
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span> <span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
<Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen"> <Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑</Button>
</> </>
)} )}
</li> </li>

View File

@@ -1,4 +1,6 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; 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";
@@ -162,8 +164,8 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> <Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"></Button> <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
</div> </div>
</div> </div>
</td> </td>
@@ -218,12 +220,12 @@ function NameChangesReview({
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))} onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
title="Behalten (Vorschlag verwerfen)" title="Behalten (Vorschlag verwerfen)"
aria-label="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"
></Button> ><FontAwesomeIcon icon={faBan} /></Button>
<Button <Button
size="icon" size="icon"
variant="primary" variant="primary"
onClick={() => applyMutation.mutate(c)} onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button> disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div> </div>
</li> </li>
))} ))}
@@ -261,8 +263,8 @@ function DeviceNameSuggestionBanner({
Per Reverse-DNS gefunden: {suggestion}" als Gerätename? Per Reverse-DNS gefunden: {suggestion}" als Gerätename?
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button> <Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button> <Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div> </div>
</div> </div>
</div> </div>
@@ -296,12 +298,12 @@ function StaleServicesReview({ staleServices, onDone }: { staleServices: Service
{s.displayName} ({s.hostname}:{s.port}) {s.displayName} ({s.hostname}:{s.port})
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button> <Button size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button <Button
size="icon" size="icon"
variant="danger" variant="danger"
onClick={() => deleteMutation.mutate(s.id)} onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑️</Button> disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</li> </li>
))} ))}
@@ -388,7 +390,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td> <td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten">✏️</Button> <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
@@ -403,7 +405,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
size="icon" size="icon"
variant="danger" variant="danger"
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑️</Button> disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -1,4 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookmark, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import { useReadLater, type ReadLaterItem } from "../../hooks/useReadLater.js"; import { useReadLater, type ReadLaterItem } from "../../hooks/useReadLater.js";
@@ -77,13 +79,13 @@ function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void })
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"></Button> <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
<Button <Button
size="icon" size="icon"
variant="primary" variant="primary"
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
disabled={mutation.isPending || !displayName.trim() || !url.trim()} disabled={mutation.isPending || !displayName.trim() || !url.trim()}
>💾</Button> ><FontAwesomeIcon icon={faXmark} /></Button>
</div> </div>
</div> </div>
{mutation.isError ? ( {mutation.isError ? (
@@ -136,15 +138,16 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) {
<td className="px-4 py-3 text-xs text-black/50 dark:text-white/50">{formatDate(item.savedAt)}</td> <td className="px-4 py-3 text-xs text-black/50 dark:text-white/50">{formatDate(item.savedAt)}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"></Button> <Button size="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button <Button
size="sm" size="icon"
variant="secondary" variant="secondary"
onClick={() => promoteMutation.mutate()} onClick={() => promoteMutation.mutate()}
disabled={promoteMutation.isPending} disabled={promoteMutation.isPending}
title="Zu Lesezeichen (Favorit) machen und aus dieser Liste entfernen" title="Zu Lesezeichen (Favorit) machen und aus dieser Liste entfernen"
aria-label="Zu Lesezeichen machen"
> >
🔖 Zu Lesezeichen <FontAwesomeIcon icon={faBookmark} />
</Button> </Button>
<Button <Button
size="icon" size="icon"
@@ -153,7 +156,7 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
title="Löschen" title="Löschen"
aria-label="Löschen" aria-label="Löschen"
>🗑</Button> ><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -1,4 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faTrash } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Device, Service } from "@launchpad/shared"; import type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
@@ -141,12 +143,12 @@ function NameChangesReview({
{c.suggested}" {c.suggested}"
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button> <Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button <Button
size="icon" size="icon"
variant="primary" variant="primary"
onClick={() => applyMutation.mutate(c)} onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button> disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div> </div>
</li> </li>
))} ))}
@@ -187,12 +189,12 @@ function StaleServicesReview({
{s.displayName} ({s.hostname}:{s.port}) {s.displayName} ({s.hostname}:{s.port})
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button> <Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button <Button
size="icon" size="icon"
variant="danger" variant="danger"
onClick={() => deleteMutation.mutate(s.id)} onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">🗑</Button> disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</li> </li>
))} ))}
@@ -234,12 +236,12 @@ function DeviceChangesReview({
{c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}" {c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}"
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button> <Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button <Button
size="icon" size="icon"
variant="primary" variant="primary"
onClick={() => applyMutation.mutate(c)} onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"></Button> disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div> </div>
</li> </li>
))} ))}
@@ -280,12 +282,12 @@ function StaleDevicesReview({
{d.hostname} ({d.ip}) {d.hostname} ({d.ip})
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"></Button> <Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button <Button
size="icon" size="icon"
variant="danger" variant="danger"
onClick={() => deleteMutation.mutate(d.id)} onClick={() => deleteMutation.mutate(d.id)}
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑</Button> disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</li> </li>
))} ))}

View File

@@ -1,4 +1,6 @@
import { useMemo, useState, type DragEvent } from "react"; import { useMemo, useState, type DragEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; 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";
@@ -229,8 +231,8 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
</div> </div>
<div className="flex w-full items-center gap-2 pt-1"> <div className="flex w-full items-center gap-2 pt-1">
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern">💾</Button> <Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"></Button> <Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
{mutation.isError ? ( {mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span> <span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null} ) : null}
@@ -394,8 +396,8 @@ function ServiceRow({
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"></Button> <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">🗑</Button> <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -20,11 +20,13 @@ function LimitSetting({
hint, hint,
settingKey, settingKey,
value: currentValue, value: currentValue,
max = 50,
}: { }: {
label: string; label: string;
hint: string; hint: string;
settingKey: "recentVisitsLimit" | "readLaterLimit"; settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays";
value: number | undefined; value: number | undefined;
max?: number;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState<string | null>(null); const [value, setValue] = useState<string | null>(null);
@@ -54,7 +56,7 @@ function LimitSetting({
<input <input
type="number" type="number"
min={0} min={0}
max={50} max={max}
value={displayed} value={displayed}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onBlur={() => { onBlur={() => {
@@ -323,6 +325,17 @@ export function SettingsPage() {
/> />
</div> </div>
<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">Scan</h2>
<LimitSetting
label="Nicht mehr gemeldete Geräte anzeigen nach (Tagen)"
hint="Ein Gerät, das die FritzBox nicht mehr meldet, wird erst nach so vielen Tagen im Scan-Ergebnis zur Durchsicht vorgeschlagen. 0 = sofort beim ersten verpassten Scan."
settingKey="staleDeviceThresholdDays"
value={settings?.staleDeviceThresholdDays}
max={90}
/>
</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">