generated from Dicken/dickendock
round16: Geraete-Erkennung per ID statt IP fixen, Schwelle konfigurierbar, Icons auf Font Awesome umgestellt
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user