generated from Dicken/dickendock
Reset-Button, Service-Reorder, Port/HTTPS-Spalten, Healthcheck in Sidebar, Kategorien-Sync
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
|
||||
import { SearchInput, StatusBadge, ResultsList, FavoritesBar } from "@launchpad/ui";
|
||||
import { rankServices, type Service } from "@launchpad/shared";
|
||||
import { useServices } from "../hooks/useServices.js";
|
||||
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
||||
@@ -52,6 +52,14 @@ export function HomePage() {
|
||||
[visibleServices, query]
|
||||
);
|
||||
|
||||
const favoriteServices = useMemo(
|
||||
() =>
|
||||
visibleServices
|
||||
.filter((s) => s.favorite)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[visibleServices]
|
||||
);
|
||||
|
||||
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
@@ -124,6 +132,12 @@ export function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xl">
|
||||
{favoriteServices.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<FavoritesBar services={favoriteServices} onOpen={openService} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { StatusBadge } from "@launchpad/ui";
|
||||
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" },
|
||||
@@ -15,6 +17,8 @@ const NAV_ITEMS = [
|
||||
export function AdminLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { health, error: healthError } = useBackendHealth();
|
||||
const isOnline = !healthError && health?.status === "ok";
|
||||
|
||||
// Drawer schließen, wenn per Navigation die Seite wechselt
|
||||
useEffect(() => {
|
||||
@@ -98,6 +102,12 @@ export function AdminLayout() {
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
|
||||
<div className="px-3 py-1.5">
|
||||
<StatusBadge
|
||||
online={isOnline}
|
||||
label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type DragEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@launchpad/ui";
|
||||
import type { Service } from "@launchpad/shared";
|
||||
@@ -37,6 +37,20 @@ async function deleteServiceRequest(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderServicesRequest(entries: { id: string; order: number }[]) {
|
||||
const res = await fetch("/api/services/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(entries),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const EDIT_FORM_COLSPAN = 9;
|
||||
|
||||
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [displayName, setDisplayName] = useState(service.displayName);
|
||||
@@ -73,7 +87,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
||||
|
||||
return (
|
||||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||||
@@ -169,7 +183,19 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRow({ service }: { service: Service }) {
|
||||
function ServiceRow({
|
||||
service,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isDragging,
|
||||
}: {
|
||||
service: Service;
|
||||
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
@@ -194,11 +220,20 @@ function ServiceRow({ service }: { service: Service }) {
|
||||
|
||||
return (
|
||||
<tr
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
|
||||
service.visible ? "" : "opacity-50"
|
||||
}`}
|
||||
} ${isDragging ? "opacity-40" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-2 py-3 text-center">
|
||||
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
|
||||
⠿⠿
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => favoriteMutation.mutate()}
|
||||
@@ -226,14 +261,24 @@ function ServiceRow({ service }: { service: Service }) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-black/40 dark:text-white/40">
|
||||
{service.hostname}:{service.port}
|
||||
</div>
|
||||
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? "–"}</td>
|
||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||||
{service.alias.length > 0 ? service.alias.join(", ") : "–"}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{service.port}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
service.https
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/50"
|
||||
}`}
|
||||
>
|
||||
{service.https ? "https" : "http"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<a
|
||||
href={service.url}
|
||||
@@ -241,7 +286,7 @@ function ServiceRow({ service }: { service: Service }) {
|
||||
rel="noopener noreferrer"
|
||||
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||||
>
|
||||
öffnen{service.https ? " (https)" : ""}
|
||||
öffnen
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -260,16 +305,60 @@ function ServiceRow({ service }: { service: Service }) {
|
||||
|
||||
export function ServicesPage() {
|
||||
const { data: services, isLoading, isError } = useServices();
|
||||
const queryClient = useQueryClient();
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: reorderServicesRequest,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
setLocalOrder(null);
|
||||
},
|
||||
onError: () => setLocalOrder(null),
|
||||
});
|
||||
|
||||
const list = localOrder ?? services ?? [];
|
||||
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
|
||||
|
||||
function handleDragStart(id: string) {
|
||||
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
|
||||
}
|
||||
|
||||
function handleDragOver(targetId: string) {
|
||||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||
e.preventDefault();
|
||||
if (!draggedId || draggedId === targetId) return;
|
||||
|
||||
const current = localOrder ?? services ?? [];
|
||||
const fromIndex = current.findIndex((s) => s.id === draggedId);
|
||||
const toIndex = current.findIndex((s) => s.id === targetId);
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
|
||||
const next = [...current];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, moved);
|
||||
setLocalOrder(next);
|
||||
};
|
||||
}
|
||||
|
||||
function handleDrop() {
|
||||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||
e.preventDefault();
|
||||
setDraggedId(null);
|
||||
const current = localOrder ?? services ?? [];
|
||||
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title="Dienste"
|
||||
description={
|
||||
hiddenCount > 0
|
||||
? `Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
||||
: "Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||||
? `Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
||||
: "Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -277,24 +366,34 @@ export function ServicesPage() {
|
||||
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste …</p>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
|
||||
) : services && services.length > 0 ? (
|
||||
) : list.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||
<th className="px-4 py-2 font-medium" />
|
||||
<th className="px-2 py-2" />
|
||||
<th className="px-2 py-2" />
|
||||
<th className="px-4 py-2 font-medium">Dienst</th>
|
||||
<th className="px-4 py-2 font-medium">Kategorie</th>
|
||||
<th className="px-4 py-2 font-medium">Alias</th>
|
||||
<th className="px-4 py-2 font-medium">Port</th>
|
||||
<th className="px-4 py-2 font-medium">Protokoll</th>
|
||||
<th className="px-4 py-2 font-medium">URL</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((service) => (
|
||||
<ServiceRow key={service.id} service={service} />
|
||||
{list.map((service) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
isDragging={draggedId === service.id}
|
||||
onDragStart={handleDragStart(service.id)}
|
||||
onDragOver={handleDragOver(service.id)}
|
||||
onDrop={handleDrop()}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@launchpad/ui";
|
||||
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||
import { useTheme } from "../../hooks/useTheme.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
@@ -11,6 +14,66 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function resetEverything() {
|
||||
const res = await fetch("/api/reset", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? `Reset fehlgeschlagen (HTTP ${res.status})`);
|
||||
}
|
||||
return body as { deletedDevices: number };
|
||||
}
|
||||
|
||||
function DangerZone() {
|
||||
const queryClient = useQueryClient();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetEverything,
|
||||
onSuccess: (result) => {
|
||||
setMessage(`${result.deletedDevices} Gerät(e) und alle zugehörigen Dienste gelöscht.`);
|
||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||
},
|
||||
onError: (err: Error) => setMessage(err.message),
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
const firstConfirm = window.confirm(
|
||||
"Wirklich ALLE Geräte und Dienste unwiderruflich löschen? Kategorien bleiben erhalten."
|
||||
);
|
||||
if (!firstConfirm) return;
|
||||
|
||||
const secondConfirm = window.confirm(
|
||||
"Ganz sicher? Das kann nicht rückgängig gemacht werden."
|
||||
);
|
||||
if (!secondConfirm) return;
|
||||
|
||||
setMessage(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-red-500/20 p-5">
|
||||
<h2 className="mb-1 font-medium text-red-600 dark:text-red-400">Gefahrenzone</h2>
|
||||
<p className="mb-4 text-sm text-black/50 dark:text-white/50">
|
||||
Löscht alle Geräte und alle zugehörigen Dienste unwiderruflich. Kategorien und Logs
|
||||
bleiben erhalten.
|
||||
</p>
|
||||
<Button variant="danger" onClick={handleClick} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Lösche …" : "Alle Geräte & Dienste löschen"}
|
||||
</Button>
|
||||
{message ? (
|
||||
<p className="mt-3 text-sm text-black/60 dark:text-white/60">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { health, error } = useBackendHealth();
|
||||
const [theme, toggleTheme] = useTheme();
|
||||
@@ -49,6 +112,8 @@ export function SettingsPage() {
|
||||
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user