generated from Dicken/dickendock
round13: manuelle vs. echte Aenderungen trennen, Scan-Uebersicht ueber Navigation persistieren, ausgeblendete Dienste als Tab, Suche ueber Geraetenamen
This commit is contained in:
@@ -53,6 +53,8 @@ export function ensureSchema(): void {
|
|||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
https INTEGER NOT NULL DEFAULT 0,
|
https INTEGER NOT NULL DEFAULT 0,
|
||||||
visible INTEGER NOT NULL DEFAULT 1,
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
|
||||||
|
category_edited_manually INTEGER NOT NULL DEFAULT 0,
|
||||||
port INTEGER NOT NULL,
|
port INTEGER NOT NULL,
|
||||||
favicon TEXT,
|
favicon TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -123,6 +125,11 @@ export function ensureSchema(): void {
|
|||||||
// sich am bisherigen Verhalten nichts unerwartet ändert.
|
// sich am bisherigen Verhalten nichts unerwartet ändert.
|
||||||
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
|
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
|
||||||
ensureColumn("categories", "color", "TEXT");
|
ensureColumn("categories", "color", "TEXT");
|
||||||
|
// Für Datenbanken vor Einführung der Unterscheidung "manuell angepasst" vs.
|
||||||
|
// "vom Scan abweichend" bei displayName/category (siehe schema.ts). Default
|
||||||
|
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
|
||||||
|
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureColumn(table: string, column: string, definition: string): void {
|
function ensureColumn(table: string, column: string, definition: string): void {
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ export function createService(input: ServiceCreateInput): Service {
|
|||||||
* Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten – die
|
* Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten – die
|
||||||
* NIEMALS displayName, category, favorite, order, alias, icon, hostname, url,
|
* NIEMALS displayName, category, favorite, order, alias, icon, hostname, url,
|
||||||
* https oder visible eines bereits bekannten Dienstes überschreibt.
|
* https oder visible eines bereits bekannten Dienstes überschreibt.
|
||||||
|
*
|
||||||
|
* Ein PATCH mit displayName/category markiert das jeweilige Feld standardmäßig
|
||||||
|
* als "manuell bearbeitet" (displayNameEditedManually/categoryEditedManually),
|
||||||
|
* damit ein späterer Scan-Vorschlag, der davon abweicht, in der Änderungs-
|
||||||
|
* übersicht klar als "weicht nur wegen deiner manuellen Anpassung ab" markiert
|
||||||
|
* werden kann statt als echte inhaltliche Änderung. Beim Übernehmen eines
|
||||||
|
* Scan-Vorschlags wird die jeweilige *EditedManually-Flag explizit auf false
|
||||||
|
* gesetzt (siehe ScannerPage/DevicesPage "Übernehmen"-Button).
|
||||||
*/
|
*/
|
||||||
export function updateService(id: string, input: ServiceUpdateInput): Service | null {
|
export function updateService(id: string, input: ServiceUpdateInput): Service | null {
|
||||||
const existing = getService(id);
|
const existing = getService(id);
|
||||||
@@ -98,12 +106,18 @@ export function updateService(id: string, input: ServiceUpdateInput): Service |
|
|||||||
|
|
||||||
db.update(services)
|
db.update(services)
|
||||||
.set({
|
.set({
|
||||||
...(input.displayName !== undefined && { displayName: input.displayName }),
|
...(input.displayName !== undefined && {
|
||||||
|
displayName: input.displayName,
|
||||||
|
displayNameEditedManually: input.displayNameEditedManually ?? true,
|
||||||
|
}),
|
||||||
...(input.hostname !== undefined && { hostname: input.hostname }),
|
...(input.hostname !== undefined && { hostname: input.hostname }),
|
||||||
...(input.url !== undefined && { url: input.url }),
|
...(input.url !== undefined && { url: input.url }),
|
||||||
...(input.https !== undefined && { https: input.https }),
|
...(input.https !== undefined && { https: input.https }),
|
||||||
...(input.port !== undefined && { port: input.port }),
|
...(input.port !== undefined && { port: input.port }),
|
||||||
...(input.category !== undefined && { category: input.category }),
|
...(input.category !== undefined && {
|
||||||
|
category: input.category,
|
||||||
|
categoryEditedManually: input.categoryEditedManually ?? true,
|
||||||
|
}),
|
||||||
...(input.icon !== undefined && { icon: input.icon }),
|
...(input.icon !== undefined && { icon: input.icon }),
|
||||||
...(input.favicon !== undefined && { favicon: input.favicon }),
|
...(input.favicon !== undefined && { favicon: input.favicon }),
|
||||||
...(input.description !== undefined && { description: input.description }),
|
...(input.description !== undefined && { description: input.description }),
|
||||||
@@ -163,6 +177,14 @@ export interface ScanNameChange {
|
|||||||
field: "displayName" | "category";
|
field: "displayName" | "category";
|
||||||
current: string | null;
|
current: string | null;
|
||||||
suggested: string;
|
suggested: string;
|
||||||
|
/**
|
||||||
|
* true, wenn der aktuell gespeicherte Wert vom Scan-Vorschlag nur deshalb
|
||||||
|
* abweicht, weil der Nutzer ihn zuvor manuell angepasst hat (siehe
|
||||||
|
* displayNameEditedManually/categoryEditedManually). Solche Änderungen sind
|
||||||
|
* erwartbar/harmlos und sollten in der UI klar von echten inhaltlichen
|
||||||
|
* Änderungen getrennt dargestellt werden.
|
||||||
|
*/
|
||||||
|
manualOverride: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanUpsertResult {
|
export interface ScanUpsertResult {
|
||||||
@@ -201,6 +223,10 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
// Rohzeile inkl. der *EditedManually-Flags laden, die mapRow() bewusst
|
||||||
|
// nicht nach außen gibt (nur intern für den Vergleich hier relevant).
|
||||||
|
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
|
||||||
|
|
||||||
db.update(services)
|
db.update(services)
|
||||||
.set({
|
.set({
|
||||||
favicon: input.favicon ?? existing.favicon,
|
favicon: input.favicon ?? existing.favicon,
|
||||||
@@ -216,6 +242,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
field: "displayName",
|
field: "displayName",
|
||||||
current: existing.displayName,
|
current: existing.displayName,
|
||||||
suggested: input.suggestedDisplayName,
|
suggested: input.suggestedDisplayName,
|
||||||
|
manualOverride: existingRow.displayNameEditedManually,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -227,6 +254,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
field: "category",
|
field: "category",
|
||||||
current: existing.category,
|
current: existing.category,
|
||||||
suggested: input.suggestedCategory,
|
suggested: input.suggestedCategory,
|
||||||
|
manualOverride: existingRow.categoryEditedManually,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ export const services = sqliteTable("services", {
|
|||||||
https: integer("https", { mode: "boolean" }).notNull().default(false),
|
https: integer("https", { mode: "boolean" }).notNull().default(false),
|
||||||
// Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich.
|
// Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich.
|
||||||
visible: integer("visible", { mode: "boolean" }).notNull().default(true),
|
visible: integer("visible", { mode: "boolean" }).notNull().default(true),
|
||||||
|
// Wird true, sobald displayName/category einmal manuell über die Admin-UI
|
||||||
|
// gesetzt wurden (siehe updateService). Dient dazu, bei einem erneuten Scan
|
||||||
|
// zu erkennen: weicht der Wert vom Scan-Vorschlag ab, WEIL der Nutzer ihn
|
||||||
|
// manuell angepasst hat (harmlos, nur zur Info) oder weil sich der
|
||||||
|
// tatsächlich erkannte Wert geändert hat (echte Änderung)?
|
||||||
|
displayNameEditedManually: integer("display_name_edited_manually", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
|
||||||
// Bei jedem Scan aktualisierbar
|
// Bei jedem Scan aktualisierbar
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
|
|||||||
40
apps/frontend/src/hooks/usePersistedState.ts
Normal file
40
apps/frontend/src/hooks/usePersistedState.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie useState, aber der Wert überlebt zusätzlich das Verlassen und erneute
|
||||||
|
* Betreten einer Seite (z. B. Scanner -> Geräte -> zurück zu Scanner), ohne
|
||||||
|
* dass ein laufender Scan erneut ausgeführt werden muss. Nutzt sessionStorage
|
||||||
|
* (pro Browser-Tab, weg nach Schließen des Tabs - bewusst kein dauerhafter
|
||||||
|
* Speicher, da es sich um vorübergehende "wartet auf Bestätigung"-Zustände
|
||||||
|
* handelt, keine echten Nutzdaten).
|
||||||
|
*/
|
||||||
|
export function usePersistedState<T>(key: string, initial: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const storageKey = `launchpad:${key}`;
|
||||||
|
|
||||||
|
const [state, setState] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(storageKey);
|
||||||
|
return raw !== null ? (JSON.parse(raw) as T) : initial;
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPersisted = useCallback(
|
||||||
|
(value: T | ((prev: T) => T)) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = typeof value === "function" ? (value as (p: T) => T)(prev) : value;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// z. B. Speicher voll oder Privatmodus - dann bleibt der Zustand
|
||||||
|
// wenigstens für den Rest dieser Sitzung im Memory erhalten.
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[storageKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setPersisted];
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button }
|
|||||||
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";
|
||||||
|
import { useDevices } from "../hooks/useDevices.js";
|
||||||
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
||||||
import { useTheme } from "../hooks/useTheme.js";
|
import { useTheme } from "../hooks/useTheme.js";
|
||||||
import { useCategories } from "../hooks/useCategories.js";
|
import { useCategories } from "../hooks/useCategories.js";
|
||||||
@@ -92,6 +93,7 @@ export function HomePage() {
|
|||||||
const { health, error: healthError } = useBackendHealth();
|
const { health, error: healthError } = useBackendHealth();
|
||||||
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
|
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
|
||||||
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
||||||
|
const { data: devices } = useDevices();
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
const { data: recentVisits } = useRecentVisits();
|
const { data: recentVisits } = useRecentVisits();
|
||||||
const { data: readLaterItems } = useReadLater();
|
const { data: readLaterItems } = useReadLater();
|
||||||
@@ -138,15 +140,20 @@ export function HomePage() {
|
|||||||
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
|
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
|
||||||
// siehe Admin -> Dienste) tauchen in der Suche nicht auf.
|
// siehe Admin -> Dienste) tauchen in der Suche nicht auf.
|
||||||
const allItems: SearchResult[] = useMemo(() => {
|
const allItems: SearchResult[] = useMemo(() => {
|
||||||
|
const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname]));
|
||||||
const visibleServices: SearchResult[] = (services ?? [])
|
const visibleServices: SearchResult[] = (services ?? [])
|
||||||
.filter((s) => s.visible)
|
.filter((s) => s.visible)
|
||||||
.map((s) => ({ ...s, kind: "service" as const }));
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
kind: "service" as const,
|
||||||
|
deviceHostname: deviceHostnameById.get(s.deviceId) ?? null,
|
||||||
|
}));
|
||||||
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
|
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
|
||||||
...b,
|
...b,
|
||||||
kind: "bookmark" as const,
|
kind: "bookmark" as const,
|
||||||
}));
|
}));
|
||||||
return [...visibleServices, ...bookmarkItems];
|
return [...visibleServices, ...bookmarkItems];
|
||||||
}, [services, bookmarks]);
|
}, [services, bookmarks, devices]);
|
||||||
|
|
||||||
const results = useMemo(() => rankServices(allItems, query), [allItems, query]);
|
const results = useMemo(() => rankServices(allItems, query), [allItems, query]);
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface ScanNameChange {
|
|||||||
field: "displayName" | "category";
|
field: "displayName" | "category";
|
||||||
current: string | null;
|
current: string | null;
|
||||||
suggested: string;
|
suggested: string;
|
||||||
|
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde. */
|
||||||
|
manualOverride: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanResult {
|
interface ScanResult {
|
||||||
@@ -184,8 +186,10 @@ function NameChangesReview({
|
|||||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation({
|
||||||
mutationFn: (change: ScanNameChange) =>
|
mutationFn: (change: ScanNameChange) => {
|
||||||
patchService(change.serviceId, { [change.field]: change.suggested }),
|
const resetFlag = change.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
|
||||||
|
return patchService(change.serviceId, { [change.field]: change.suggested, [resetFlag]: false });
|
||||||
|
},
|
||||||
onSuccess: (_data, change) => {
|
onSuccess: (_data, change) => {
|
||||||
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
@@ -199,14 +203,13 @@ function NameChangesReview({
|
|||||||
|
|
||||||
const fieldLabel = (field: ScanNameChange["field"]) =>
|
const fieldLabel = (field: ScanNameChange["field"]) =>
|
||||||
field === "displayName" ? "Name" : "Kategorie";
|
field === "displayName" ? "Name" : "Kategorie";
|
||||||
|
const manual = remaining.filter((c) => c.manualOverride);
|
||||||
|
const real = remaining.filter((c) => !c.manualOverride);
|
||||||
|
|
||||||
|
function renderList(list: ScanNameChange[]) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
|
||||||
{remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{remaining.map((c) => (
|
{list.map((c) => (
|
||||||
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
|
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
|
||||||
@@ -231,7 +234,28 @@ function NameChangesReview({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{real.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
|
{real.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
|
{renderList(real)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{manual.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-black/10 bg-black/[0.02] p-3 text-xs dark:border-white/10 dark:bg-white/[0.02]">
|
||||||
|
<p className="mb-2 font-medium text-black/60 dark:text-white/60">
|
||||||
|
{manual.length} Abweichung(en), weil du diesen Wert bereits manuell angepasst hast:
|
||||||
|
</p>
|
||||||
|
{renderList(manual)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button onClick={onDone} className="text-black/40 underline dark:text-white/40">
|
||||||
Hinweis schließen
|
Hinweis schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
|
||||||
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 { Device, Service } from "@launchpad/shared";
|
import type { Device, Service } from "@launchpad/shared";
|
||||||
import { useDevices } from "../../hooks/useDevices.js";
|
import { useDevices } from "../../hooks/useDevices.js";
|
||||||
|
import { usePersistedState } from "../../hooks/usePersistedState.js";
|
||||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
interface DeviceChange {
|
interface DeviceChange {
|
||||||
@@ -62,6 +62,8 @@ interface ScanNameChange {
|
|||||||
field: "displayName" | "category";
|
field: "displayName" | "category";
|
||||||
current: string | null;
|
current: string | null;
|
||||||
suggested: string;
|
suggested: string;
|
||||||
|
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde (siehe unten). */
|
||||||
|
manualOverride: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchService(id: string, patch: Record<string, unknown>) {
|
async function patchService(id: string, patch: Record<string, unknown>) {
|
||||||
@@ -91,100 +93,128 @@ async function scanDeviceById(id: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nameChangeKey(c: ScanNameChange): string {
|
||||||
|
return `${c.serviceId}:${c.field}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
|
||||||
|
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
||||||
|
* danach wieder als "vom Scan" statt "manuell angepasst" gilt.
|
||||||
|
*/
|
||||||
|
function acceptNameChange(c: ScanNameChange) {
|
||||||
|
const resetFlag = c.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
|
||||||
|
return patchService(c.serviceId, { [c.field]: c.suggested, [resetFlag]: false });
|
||||||
|
}
|
||||||
|
|
||||||
function NameChangesReview({
|
function NameChangesReview({
|
||||||
nameChanges,
|
nameChanges,
|
||||||
onDone,
|
onResolve,
|
||||||
}: {
|
}: {
|
||||||
nameChanges: ScanNameChange[];
|
nameChanges: ScanNameChange[];
|
||||||
onDone: () => void;
|
onResolve: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation({
|
||||||
mutationFn: (change: ScanNameChange) =>
|
mutationFn: acceptNameChange,
|
||||||
patchService(change.serviceId, { [change.field]: change.suggested }),
|
|
||||||
onSuccess: (_data, change) => {
|
onSuccess: (_data, change) => {
|
||||||
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
onResolve(nameChangeKey(change));
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const remaining = nameChanges.filter((c) => !handled.has(`${c.serviceId}:${c.field}`));
|
if (nameChanges.length === 0) return null;
|
||||||
if (remaining.length === 0) return null;
|
|
||||||
|
|
||||||
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
|
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
|
||||||
|
const manual = nameChanges.filter((c) => c.manualOverride);
|
||||||
|
const real = nameChanges.filter((c) => !c.manualOverride);
|
||||||
|
|
||||||
|
function renderList(list: ScanNameChange[]) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
|
||||||
{remaining.length} erkannte Änderung(en) über alle gescannten Geräte hinweg weichen vom
|
|
||||||
gespeicherten Wert ab:
|
|
||||||
</p>
|
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{remaining.map((c) => (
|
{list.map((c) => (
|
||||||
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
<li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „
|
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „
|
||||||
{c.suggested}"
|
{c.suggested}"
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button size="sm" variant="ghost" onClick={() => onResolve(nameChangeKey(c))}>
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
|
|
||||||
>
|
|
||||||
Behalten
|
Behalten
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate(c)} disabled={applyMutation.isPending}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => applyMutation.mutate(c)}
|
||||||
|
disabled={applyMutation.isPending}
|
||||||
|
>
|
||||||
Übernehmen
|
Übernehmen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
);
|
||||||
Hinweis schließen
|
}
|
||||||
</button>
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{real.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
|
{real.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
|
{renderList(real)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{manual.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-black/10 bg-black/[0.02] p-3 text-xs dark:border-white/10 dark:bg-white/[0.02]">
|
||||||
|
<p className="mb-2 font-medium text-black/60 dark:text-white/60">
|
||||||
|
{manual.length} Abweichung(en), weil du diesen Wert bereits manuell angepasst hast – der
|
||||||
|
Scan schlägt lediglich erneut den automatisch erkannten Wert vor, es hat sich nichts am
|
||||||
|
Dienst selbst geändert:
|
||||||
|
</p>
|
||||||
|
{renderList(manual)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StaleServicesReview({
|
function StaleServicesReview({
|
||||||
staleServices,
|
staleServices,
|
||||||
onDone,
|
onResolve,
|
||||||
}: {
|
}: {
|
||||||
staleServices: Service[];
|
staleServices: Service[];
|
||||||
onDone: () => void;
|
onResolve: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => deleteServiceRequest(id),
|
mutationFn: (id: string) => deleteServiceRequest(id),
|
||||||
onSuccess: (_data, id) => {
|
onSuccess: (_data, id) => {
|
||||||
setHandled((prev) => new Set(prev).add(id));
|
onResolve(id);
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const remaining = staleServices.filter((s) => !handled.has(s.id));
|
if (staleServices.length === 0) return null;
|
||||||
if (remaining.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
||||||
{remaining.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
|
{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
|
||||||
nicht mehr offen):
|
nicht mehr offen):
|
||||||
</p>
|
</p>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{remaining.map((s) => (
|
{staleServices.map((s) => (
|
||||||
<li key={s.id} className="flex items-center justify-between gap-2">
|
<li key={s.id} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{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="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))}>
|
<Button size="sm" variant="ghost" onClick={() => onResolve(s.id)}>
|
||||||
Behalten
|
Behalten
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -199,98 +229,95 @@ function StaleServicesReview({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
|
||||||
Hinweis schließen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceChangesReview({
|
function DeviceChangesReview({
|
||||||
deviceChanges,
|
deviceChanges,
|
||||||
onDone,
|
onResolve,
|
||||||
}: {
|
}: {
|
||||||
deviceChanges: DeviceChange[];
|
deviceChanges: DeviceChange[];
|
||||||
onDone: () => void;
|
onResolve: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation({
|
||||||
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
|
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
|
||||||
onSuccess: (_data, change) => {
|
onSuccess: (_data, change) => {
|
||||||
setHandled((prev) => new Set(prev).add(`${change.deviceId}:${change.field}`));
|
onResolve(`${change.deviceId}:${change.field}`);
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const remaining = deviceChanges.filter((c) => !handled.has(`${c.deviceId}:${c.field}`));
|
if (deviceChanges.length === 0) return null;
|
||||||
if (remaining.length === 0) return null;
|
|
||||||
|
|
||||||
const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP");
|
const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
{remaining.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
||||||
</p>
|
</p>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{remaining.map((c) => (
|
{deviceChanges.map((c) => (
|
||||||
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{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
|
<Button size="sm" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)}>
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setHandled((prev) => new Set(prev).add(`${c.deviceId}:${c.field}`))}
|
|
||||||
>
|
|
||||||
Behalten
|
Behalten
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate(c)} disabled={applyMutation.isPending}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => applyMutation.mutate(c)}
|
||||||
|
disabled={applyMutation.isPending}
|
||||||
|
>
|
||||||
Übernehmen
|
Übernehmen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
|
||||||
Hinweis schließen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; onDone: () => void }) {
|
function StaleDevicesReview({
|
||||||
|
staleDevices,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
staleDevices: Device[];
|
||||||
|
onResolve: (id: string) => void;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => deleteDeviceRequest(id),
|
mutationFn: (id: string) => deleteDeviceRequest(id),
|
||||||
onSuccess: (_data, id) => {
|
onSuccess: (_data, id) => {
|
||||||
setHandled((prev) => new Set(prev).add(id));
|
onResolve(id);
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const remaining = staleDevices.filter((d) => !handled.has(d.id));
|
if (staleDevices.length === 0) return null;
|
||||||
if (remaining.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
||||||
{remaining.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||||
mehr:
|
mehr:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{remaining.map((d) => (
|
{staleDevices.map((d) => (
|
||||||
<li key={d.id} className="flex items-center justify-between gap-2">
|
<li key={d.id} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{d.hostname} ({d.ip})
|
{d.hostname} ({d.ip})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(d.id))}>
|
<Button size="sm" variant="ghost" onClick={() => onResolve(d.id)}>
|
||||||
Behalten
|
Behalten
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -305,9 +332,6 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[];
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
|
||||||
Hinweis schließen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -315,22 +339,41 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[];
|
|||||||
export function ScannerPage() {
|
export function ScannerPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: devices } = useDevices();
|
const { data: devices } = useDevices();
|
||||||
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
|
|
||||||
const [bulkRunning, setBulkRunning] = useState(false);
|
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
|
||||||
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]);
|
const [bulkRunning, setBulkRunning] = usePersistedState<boolean>("scanner:bulkRunning", false);
|
||||||
const [bulkNameChanges, setBulkNameChanges] = useState<ScanNameChange[]>([]);
|
const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
|
||||||
const [staleDevices, setStaleDevices] = useState<Device[]>([]);
|
"scanner:bulkStaleServices",
|
||||||
const [deviceChanges, setDeviceChanges] = useState<DeviceChange[]>([]);
|
[]
|
||||||
|
);
|
||||||
|
const [bulkNameChanges, setBulkNameChanges] = usePersistedState<ScanNameChange[]>(
|
||||||
|
"scanner:bulkNameChanges",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [staleDevices, setStaleDevices] = usePersistedState<Device[]>("scanner:staleDevices", []);
|
||||||
|
const [deviceChanges, setDeviceChanges] = usePersistedState<DeviceChange[]>(
|
||||||
|
"scanner:deviceChanges",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [fritzboxSummary, setFritzboxSummary] = usePersistedState<
|
||||||
|
{ found: number; uniqueDevices: number } | null
|
||||||
|
>("scanner:fritzboxSummary", null);
|
||||||
|
const [fritzboxError, setFritzboxError] = usePersistedState<string | null>(
|
||||||
|
"scanner:fritzboxError",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const fritzboxMutation = useMutation({
|
const fritzboxMutation = useMutation({
|
||||||
mutationFn: scanFritzBox,
|
mutationFn: scanFritzBox,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
setFritzboxSummary({ found: result.found, uniqueDevices: result.uniqueDevices });
|
||||||
|
setFritzboxError(null);
|
||||||
setStaleDevices(result.staleDevices);
|
setStaleDevices(result.staleDevices);
|
||||||
setDeviceChanges(result.deviceChanges);
|
setDeviceChanges(result.deviceChanges);
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
|
onError: (error: Error) => setFritzboxError(error.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function scanAllDevices() {
|
async function scanAllDevices() {
|
||||||
@@ -356,12 +399,6 @@ export function ScannerPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "updated" zählt jeden bereits bekannten Dienst, dessen Favicon/Beschreibung
|
|
||||||
// aufgefrischt wurde - das ist normal und keine "Änderung" im Sinne von
|
|
||||||
// Namens-/Kategorie-Anpassungen. Deshalb hier NICHT mit anzeigen, um nicht
|
|
||||||
// den Eindruck zu erwecken, es sei bereits etwas Wichtiges verändert
|
|
||||||
// worden. Echte Änderungen/verschwundene Dienste stehen explizit unten in
|
|
||||||
// den Listen und müssen einzeln bestätigt werden.
|
|
||||||
void updated;
|
void updated;
|
||||||
const pending = allNameChanges.length + allStale.length;
|
const pending = allNameChanges.length + allStale.length;
|
||||||
setBulkStatus(
|
setBulkStatus(
|
||||||
@@ -403,25 +440,25 @@ export function ScannerPage() {
|
|||||||
>
|
>
|
||||||
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
|
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
|
||||||
</Button>
|
</Button>
|
||||||
{fritzboxMutation.isSuccess ? (
|
{fritzboxSummary ? (
|
||||||
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
{fritzboxMutation.data.found} Eintrag/Einträge von der FritzBox,{" "}
|
{fritzboxSummary.found} Eintrag/Einträge von der FritzBox,{" "}
|
||||||
{fritzboxMutation.data.uniqueDevices} eindeutige Geräte
|
{fritzboxSummary.uniqueDevices} eindeutige Geräte
|
||||||
{fritzboxMutation.data.found !== fritzboxMutation.data.uniqueDevices
|
{fritzboxSummary.found !== fritzboxSummary.uniqueDevices
|
||||||
? " (mehrere Einträge desselben Geräts wurden zusammengeführt)"
|
? " (mehrere Einträge desselben Geräts wurden zusammengeführt)"
|
||||||
: ""}
|
: ""}
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{fritzboxMutation.isError ? (
|
{fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
|
||||||
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p>
|
<DeviceChangesReview
|
||||||
) : null}
|
deviceChanges={deviceChanges}
|
||||||
{deviceChanges.length > 0 ? (
|
onResolve={(key) => setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))}
|
||||||
<DeviceChangesReview deviceChanges={deviceChanges} onDone={() => setDeviceChanges([])} />
|
/>
|
||||||
) : null}
|
<StaleDevicesReview
|
||||||
{staleDevices.length > 0 ? (
|
staleDevices={staleDevices}
|
||||||
<StaleDevicesReview staleDevices={staleDevices} onDone={() => setStaleDevices([])} />
|
onResolve={(id) => setStaleDevices((prev) => prev.filter((d) => d.id !== id))}
|
||||||
) : null}
|
/>
|
||||||
</div>
|
</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">
|
||||||
@@ -442,15 +479,14 @@ export function ScannerPage() {
|
|||||||
{bulkStatus ? (
|
{bulkStatus ? (
|
||||||
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{bulkNameChanges.length > 0 ? (
|
<NameChangesReview
|
||||||
<NameChangesReview nameChanges={bulkNameChanges} onDone={() => setBulkNameChanges([])} />
|
nameChanges={bulkNameChanges}
|
||||||
) : null}
|
onResolve={(key) => setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))}
|
||||||
{bulkStaleServices.length > 0 ? (
|
/>
|
||||||
<StaleServicesReview
|
<StaleServicesReview
|
||||||
staleServices={bulkStaleServices}
|
staleServices={bulkStaleServices}
|
||||||
onDone={() => setBulkStaleServices([])}
|
onResolve={(id) => setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ export function ServicesPage() {
|
|||||||
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
|
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible");
|
||||||
|
|
||||||
const macByDeviceId = useMemo(() => {
|
const macByDeviceId = useMemo(() => {
|
||||||
const map: Record<string, string | null> = {};
|
const map: Record<string, string | null> = {};
|
||||||
@@ -456,10 +457,15 @@ export function ServicesPage() {
|
|||||||
|
|
||||||
const baseList = localOrder ?? services ?? [];
|
const baseList = localOrder ?? services ?? [];
|
||||||
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
|
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
|
||||||
|
const visibleCount = services ? services.length - hiddenCount : 0;
|
||||||
|
const tabFilteredBase = useMemo(
|
||||||
|
() => baseList.filter((s) => (activeTab === "visible" ? s.visible : !s.visible)),
|
||||||
|
[baseList, activeTab]
|
||||||
|
);
|
||||||
|
|
||||||
const list = useMemo(() => {
|
const list = useMemo(() => {
|
||||||
if (!sortColumn) return baseList;
|
if (!sortColumn) return tabFilteredBase;
|
||||||
const sorted = [...baseList].sort((a, b) => {
|
const sorted = [...tabFilteredBase].sort((a, b) => {
|
||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
switch (sortColumn) {
|
switch (sortColumn) {
|
||||||
case "displayName":
|
case "displayName":
|
||||||
@@ -491,7 +497,7 @@ export function ServicesPage() {
|
|||||||
return sortDirection === "asc" ? cmp : -cmp;
|
return sortDirection === "asc" ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [baseList, sortColumn, sortDirection, ipByDeviceId]);
|
}, [tabFilteredBase, sortColumn, sortDirection, ipByDeviceId]);
|
||||||
|
|
||||||
function handleHeaderClick(column: SortColumn) {
|
function handleHeaderClick(column: SortColumn) {
|
||||||
if (sortColumn === column) {
|
if (sortColumn === column) {
|
||||||
@@ -539,13 +545,32 @@ export function ServicesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title="Dienste"
|
title="Dienste"
|
||||||
description={
|
description="Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||||||
hiddenCount > 0
|
|
||||||
? `Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
|
||||||
: "Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 flex gap-1 border-b border-black/10 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("visible")}
|
||||||
|
className={`px-3 py-2 text-sm font-medium ${
|
||||||
|
activeTab === "visible"
|
||||||
|
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||||||
|
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Sichtbar ({visibleCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("hidden")}
|
||||||
|
className={`px-3 py-2 text-sm font-medium ${
|
||||||
|
activeTab === "hidden"
|
||||||
|
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||||||
|
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🙈 Ausgeblendet ({hiddenCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sortColumn ? (
|
{sortColumn ? (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||||||
@@ -600,8 +625,9 @@ export function ServicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-black/40 dark:text-white/40">
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche
|
{activeTab === "hidden"
|
||||||
zu finden, oder importiere eine Liste oben.
|
? "Keine ausgeblendeten Dienste."
|
||||||
|
: "Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export interface Service {
|
|||||||
order: number;
|
order: number;
|
||||||
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
|
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
/**
|
||||||
|
* Nicht vom Backend geliefert, sondern optional vom Frontend ergänzt (aus
|
||||||
|
* den bekannten Geräten), damit die Suche auch über den sprechenden
|
||||||
|
* Gerätenamen greift - `hostname` ist bei Diensten oft nur die IP, wenn
|
||||||
|
* keine DNS-Auflösung möglich war.
|
||||||
|
*/
|
||||||
|
deviceHostname?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
@@ -102,6 +109,8 @@ export interface Rankable {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
|
/** Optional: sprechender Gerätename, zusätzlich zum technischen hostname-Feld durchsucht. */
|
||||||
|
deviceHostname?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,15 +142,16 @@ export function rankService<T extends Rankable>(item: T, query: string): number
|
|||||||
|
|
||||||
const displayName = item.displayName.toLowerCase();
|
const displayName = item.displayName.toLowerCase();
|
||||||
const hostname = item.hostname.toLowerCase();
|
const hostname = item.hostname.toLowerCase();
|
||||||
|
const deviceHostname = (item.deviceHostname ?? "").toLowerCase();
|
||||||
const description = (item.description ?? "").toLowerCase();
|
const description = (item.description ?? "").toLowerCase();
|
||||||
const alias = item.alias.map((a) => a.toLowerCase());
|
const alias = item.alias.map((a) => a.toLowerCase());
|
||||||
|
|
||||||
if (displayName.startsWith(q)) return 1;
|
if (displayName.startsWith(q)) return 1;
|
||||||
if (alias.some((a) => a.startsWith(q))) return 2;
|
if (alias.some((a) => a.startsWith(q))) return 2;
|
||||||
if (hostname.startsWith(q)) return 3;
|
if (hostname.startsWith(q) || deviceHostname.startsWith(q)) return 3;
|
||||||
if (displayName.includes(q)) return 4;
|
if (displayName.includes(q)) return 4;
|
||||||
if (alias.some((a) => a.includes(q))) return 5;
|
if (alias.some((a) => a.includes(q))) return 5;
|
||||||
if (hostname.includes(q)) return 6;
|
if (hostname.includes(q) || deviceHostname.includes(q)) return 6;
|
||||||
if (description.includes(q)) return 7;
|
if (description.includes(q)) return 7;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({
|
|||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
icon: z.string().nullable().optional(),
|
icon: z.string().nullable().optional(),
|
||||||
favicon: z.string().nullable().optional(),
|
favicon: z.string().nullable().optional(),
|
||||||
|
// Standardmäßig markiert ein PATCH mit displayName/category den jeweiligen
|
||||||
|
// Wert als "manuell bearbeitet" (siehe updateService-Doku in
|
||||||
|
// repositories/services.ts). Wird explizit false mitgeschickt - z. B. beim
|
||||||
|
// Übernehmen eines Scan-Vorschlags in der Änderungsübersicht -, bleibt der
|
||||||
|
// Wert stattdessen als "vom Scan" markiert.
|
||||||
|
displayNameEditedManually: z.boolean().optional(),
|
||||||
|
categoryEditedManually: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user