generated from Dicken/dickendock
round27: Ping-Regression rueckgaengig gemacht (Echo/Samsung-Geraete ignorieren ICMP), Aktualisiert-Text entfernt, Suchmaschinen-Hinweis+README, API-Scanner stark erweitert mit eigenem Reiter
This commit is contained in:
69
README.md
69
README.md
@@ -274,6 +274,75 @@ POST /api/plugins/:name/import löst importDevices() eines Plugins aus
|
|||||||
/admin/logs
|
/admin/logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Als Browser-Suchmaschine einrichten
|
||||||
|
|
||||||
|
LaunchPad lässt sich als eigene Suchmaschine mit Kürzel in Chrome, Edge und
|
||||||
|
Firefox hinterlegen – danach reicht z. B. `lp proxmox` in der Adresszeile,
|
||||||
|
um direkt beim passenden Dienst zu landen (ohne LaunchPad überhaupt erst zu
|
||||||
|
öffnen). Das ist ein **einmaliger, manueller Schritt pro Browser/Gerät** –
|
||||||
|
kein Browser lässt eine Website das automatisch für sich einrichten,
|
||||||
|
aus gutem Grund (Sicherheit).
|
||||||
|
|
||||||
|
**Chrome / Edge:**
|
||||||
|
1. Einstellungen → „Suchmaschine" → „Suchmaschinen verwalten"
|
||||||
|
2. „Website-Suchmaschine hinzufügen"
|
||||||
|
3. Suchmaschine: `LaunchPad`, Kürzel: `lp`, URL: `https://<deine-domain-oder-ip>:8443/search?q=%s`
|
||||||
|
|
||||||
|
**Firefox:**
|
||||||
|
1. Einmal `https://<deine-domain-oder-ip>:8443/` öffnen (Firefox erkennt den
|
||||||
|
hinterlegten OpenSearch-Eintrag automatisch)
|
||||||
|
2. Einstellungen → „Suche" → bei „LaunchPad" ein Schlagwort (z. B. `lp`)
|
||||||
|
eintragen
|
||||||
|
|
||||||
|
Danach: `lp <suchbegriff>` in die Adresszeile, Enter – landet bei eindeutigem
|
||||||
|
Treffer direkt beim Dienst, sonst auf der LaunchPad-Startseite mit bereits
|
||||||
|
eingetragenem Suchbegriff.
|
||||||
|
|
||||||
|
## Deployment auf unterschiedlichen Geräten
|
||||||
|
|
||||||
|
Läuft überall, wo Docker Compose verfügbar ist. Ein paar konkrete Wege:
|
||||||
|
|
||||||
|
### Generischer Linux-Server / LXC-Container (Proxmox, etc.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> LaunchPad
|
||||||
|
cd LaunchPad
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synology NAS mit Container Manager (Portainer-ähnliche Oberfläche, DSM 7.2+)
|
||||||
|
|
||||||
|
1. Repo per `git clone` auf die NAS holen (z. B. per SSH, oder mit File
|
||||||
|
Station hochladen) – Zielordner z. B. `/volume1/docker/LaunchPad`.
|
||||||
|
2. In Container Manager → „Projekt" → „Erstellen" → als Pfad den geklonten
|
||||||
|
Ordner wählen. Container Manager erkennt die `docker-compose.yml`
|
||||||
|
automatisch und bietet an, sie als Projekt zu importieren.
|
||||||
|
3. `.env` vorher (per File Station oder SSH) aus `.env.example` anlegen und
|
||||||
|
anpassen – Container Manager selbst bietet dafür keine Oberfläche.
|
||||||
|
4. Projekt starten. Ports `8080`/`8443` müssen frei sein (in DSM ggf. mit
|
||||||
|
bereits belegten NAS-eigenen Ports abgleichen, notfalls in
|
||||||
|
`docker-compose.yml` die linke Seite der Port-Zuordnung ändern, z. B.
|
||||||
|
`18080:80`).
|
||||||
|
|
||||||
|
### Synology NAS mit Portainer (falls separat installiert)
|
||||||
|
|
||||||
|
1. Repo wie oben auf die NAS holen.
|
||||||
|
2. Portainer → „Stacks" → „Add stack" → „Repository" (Git-URL direkt
|
||||||
|
eintragen) oder „Upload" (`docker-compose.yml` hochladen).
|
||||||
|
3. Unter „Environment variables" die Inhalte aus `.env.example` eintragen
|
||||||
|
(Portainer braucht dafür keine separate `.env`-Datei, nimmt die Stack-Vars).
|
||||||
|
4. „Deploy the stack".
|
||||||
|
|
||||||
|
### DockHand / andere Docker-Compose-Oberflächen
|
||||||
|
|
||||||
|
Jede Oberfläche, die eine vorhandene `docker-compose.yml` importieren oder
|
||||||
|
auf einen Git-Ordner zeigen kann, funktioniert grundsätzlich gleich: Repo
|
||||||
|
bereitstellen (lokal geklont oder per Git-Integration der jeweiligen
|
||||||
|
Oberfläche), `.env` aus `.env.example` befüllen, Compose-Datei
|
||||||
|
starten/deployen. Es gibt keine LaunchPad-spezifischen Besonderheiten
|
||||||
|
außer den Standard-Docker-Compose-Grundlagen (Ports, Volumes, `.env`).
|
||||||
|
|
||||||
## Deployment auf dem Server (xlc-launchpad)
|
## Deployment auf dem Server (xlc-launchpad)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -27,15 +27,29 @@ export function listDetectedApis(): DetectedApiEntry[] {
|
|||||||
return db.select().from(detectedApis).all().map(mapRow);
|
return db.select().from(detectedApis).all().map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getApisForService(serviceId: string): DetectedApiEntry[] {
|
||||||
|
return db.select().from(detectedApis).where(eq(detectedApis.serviceId, serviceId)).all().map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ersetzt die gespeicherten API-Funde eines Dienstes komplett durch die
|
* Ersetzt die gespeicherten API-Funde eines Dienstes komplett durch die
|
||||||
* neuen Ergebnisse eines Scan-Laufs - kein Anhäufen von veralteten
|
* neuen Ergebnisse eines Scan-Laufs - kein Anhäufen von veralteten
|
||||||
* Einträgen, wenn sich z. B. der API-Pfad einer Software mal ändert.
|
* Einträgen, wenn sich z. B. der API-Pfad einer Software mal ändert.
|
||||||
|
*
|
||||||
|
* Liefert zusätzlich `added` zurück: die Einträge, die VORHER noch nicht
|
||||||
|
* gespeichert waren (nach Pfad+Typ verglichen) - damit der Scanner auf der
|
||||||
|
* Scanner-Seite nach dem ersten Mal nur noch NEUE/geänderte Funde anzeigen
|
||||||
|
* kann, statt bei jedem erneuten Scan die komplette (meist unveränderte)
|
||||||
|
* Liste erneut runterzurattern. Die vollständige Liste bleibt jederzeit
|
||||||
|
* unter Admin -> APIs einsehbar.
|
||||||
*/
|
*/
|
||||||
export function replaceApisForService(
|
export function replaceApisForService(
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
found: { path: string; type: string; status: number }[]
|
found: { path: string; type: string; status: number }[]
|
||||||
): DetectedApiEntry[] {
|
): { saved: DetectedApiEntry[]; added: DetectedApiEntry[] } {
|
||||||
|
const before = getApisForService(serviceId);
|
||||||
|
const beforeKeys = new Set(before.map((e) => `${e.path}::${e.type}`));
|
||||||
|
|
||||||
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -52,7 +66,10 @@ export function replaceApisForService(
|
|||||||
db.insert(detectedApis).values(rows).run();
|
db.insert(detectedApis).values(rows).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.map(mapRow);
|
const saved = rows.map(mapRow);
|
||||||
|
const added = saved.filter((e) => !beforeKeys.has(`${e.path}::${e.type}`));
|
||||||
|
|
||||||
|
return { saved, added };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteApisForService(serviceId: string): void {
|
export function deleteApisForService(serviceId: string): void {
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const services = serviceRepo.listServices();
|
const services = serviceRepo.listServices();
|
||||||
let servicesWithApi = 0;
|
let servicesWithApi = 0;
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
|
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||||
|
|
||||||
for (const service of services) {
|
for (const service of services) {
|
||||||
const found = await detectApis(service.url);
|
const found = await detectApis(service.url);
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
apiRepo.replaceApisForService(service.id, found);
|
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||||
servicesWithApi++;
|
servicesWithApi++;
|
||||||
totalFound += found.length;
|
totalFound += found.length;
|
||||||
|
if (added.length > 0) {
|
||||||
|
newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
apiRepo.deleteApisForService(service.id);
|
apiRepo.deleteApisForService(service.id);
|
||||||
}
|
}
|
||||||
@@ -38,7 +42,7 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { checked: services.length, servicesWithApi, totalFound };
|
return { checked: services.length, servicesWithApi, totalFound, newFindings };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
||||||
@@ -49,9 +53,10 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const found = await detectApis(service.url);
|
const found = await detectApis(service.url);
|
||||||
const saved = found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : [];
|
const { saved, added } =
|
||||||
|
found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : { saved: [], added: [] };
|
||||||
if (found.length === 0) apiRepo.deleteApisForService(service.id);
|
if (found.length === 0) apiRepo.deleteApisForService(service.id);
|
||||||
|
|
||||||
return { serviceId, apis: saved };
|
return { serviceId, apis: saved, added };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface DetectedApi {
|
|||||||
interface RawProbeResult {
|
interface RawProbeResult {
|
||||||
status: number;
|
status: number;
|
||||||
contentType: string | null;
|
contentType: string | null;
|
||||||
|
wwwAuthenticate: string | null;
|
||||||
bodySnippet: string;
|
bodySnippet: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,13 @@ function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null>
|
|||||||
});
|
});
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
const contentType = res.headers["content-type"] ?? null;
|
const contentType = res.headers["content-type"] ?? null;
|
||||||
resolve({ status: res.statusCode ?? 0, contentType, bodySnippet: body });
|
const wwwAuthenticate = res.headers["www-authenticate"] ?? null;
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode ?? 0,
|
||||||
|
contentType,
|
||||||
|
wwwAuthenticate: Array.isArray(wwwAuthenticate) ? wwwAuthenticate[0] : wwwAuthenticate,
|
||||||
|
bodySnippet: body,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
res.on("error", () => resolve(null));
|
res.on("error", () => resolve(null));
|
||||||
}
|
}
|
||||||
@@ -47,20 +54,58 @@ function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise
|
* Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise
|
||||||
* ihre API bzw. deren Dokumentation/Schema anbietet. Bewusst eine
|
* ihre API bzw. deren Dokumentation/Schema anbietet - sowohl generische
|
||||||
* kuratierte, kurze Liste statt eines vollständigen Wortlisten-Bruteforce -
|
* REST/OpenAPI/GraphQL-Konventionen als auch Pfade konkreter, in Homelabs
|
||||||
* das hier ist ein Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
* verbreiteter Software (Home Assistant, Proxmox, Portainer, die *arr-Reihe,
|
||||||
|
* Grafana, Jellyfin/Plex, Pi-hole, Unifi, ...). Bewusst eine kuratierte
|
||||||
|
* Liste statt eines vollständigen Wortlisten-Bruteforce - das hier ist ein
|
||||||
|
* Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||||
*/
|
*/
|
||||||
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
||||||
|
// Generische REST-/OpenAPI-/GraphQL-Konventionen
|
||||||
{ path: "/openapi.json", type: "OpenAPI" },
|
{ path: "/openapi.json", type: "OpenAPI" },
|
||||||
|
{ path: "/openapi.yaml", type: "OpenAPI" },
|
||||||
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
||||||
|
{ path: "/swagger.yaml", type: "OpenAPI (Swagger)" },
|
||||||
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
||||||
|
{ path: "/v2/api-docs", type: "OpenAPI (Swagger)" },
|
||||||
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
||||||
|
{ path: "/swagger-ui", type: "Swagger-UI" },
|
||||||
|
{ path: "/redoc", type: "OpenAPI (ReDoc)" },
|
||||||
{ path: "/docs", type: "API-Dokumentation" },
|
{ path: "/docs", type: "API-Dokumentation" },
|
||||||
{ path: "/graphql", type: "GraphQL" },
|
{ path: "/graphql", type: "GraphQL" },
|
||||||
|
{ path: "/graphiql", type: "GraphQL" },
|
||||||
|
{ path: "/api/graphql", type: "GraphQL" },
|
||||||
|
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||||
|
{ path: "/api/v3", type: "REST-API" },
|
||||||
|
{ path: "/api/v2", type: "REST-API" },
|
||||||
{ path: "/api/v1", type: "REST-API" },
|
{ path: "/api/v1", type: "REST-API" },
|
||||||
{ path: "/api", type: "REST-API" },
|
{ path: "/api", type: "REST-API" },
|
||||||
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
{ path: "/rest", type: "REST-API" },
|
||||||
|
{ path: "/rpc", type: "JSON-RPC" },
|
||||||
|
{ path: "/jsonrpc", type: "JSON-RPC" },
|
||||||
|
{ path: "/api/status", type: "REST-API" },
|
||||||
|
{ path: "/api/system", type: "REST-API" },
|
||||||
|
{ path: "/api/info", type: "REST-API" },
|
||||||
|
{ path: "/api/version", type: "REST-API" },
|
||||||
|
{ path: "/api/config", type: "REST-API" },
|
||||||
|
{ path: "/actuator", type: "Spring-Boot-Actuator" },
|
||||||
|
{ path: "/actuator/health", type: "Spring-Boot-Actuator" },
|
||||||
|
{ path: "/metrics", type: "Metriken (Prometheus-Format)" },
|
||||||
|
{ path: "/healthz", type: "Health-Endpunkt" },
|
||||||
|
{ path: "/health", type: "Health-Endpunkt" },
|
||||||
|
// Konkrete, in Homelabs verbreitete Software
|
||||||
|
{ path: "/api/config/core", type: "Home Assistant API" }, // erfordert Auth, meldet sich aber als API
|
||||||
|
{ path: "/api2/json/version", type: "Proxmox API" },
|
||||||
|
{ path: "/api/status", type: "Portainer API" },
|
||||||
|
{ path: "/api/v3/system/status", type: "Sonarr/Radarr/Prowlarr API" },
|
||||||
|
{ path: "/admin/api.php", type: "Pi-hole API" },
|
||||||
|
{ path: "/System/Info/Public", type: "Jellyfin API" },
|
||||||
|
{ path: "/identity", type: "Plex API" },
|
||||||
|
{ path: "/api/health", type: "Grafana API" },
|
||||||
|
{ path: "/api/self", type: "Unifi-Controller API" },
|
||||||
|
{ path: "/api/v2/server/about", type: "Nextcloud API" },
|
||||||
|
{ path: "/ocs/v1.php/cloud/capabilities", type: "Nextcloud API" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function looksLikeJson(body: string): boolean {
|
function looksLikeJson(body: string): boolean {
|
||||||
@@ -70,11 +115,15 @@ function looksLikeJson(body: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft die kuratierten Kandidaten-Pfade unter einer Basis-URL parallel und
|
* Prüft die kuratierten Kandidaten-Pfade unter einer Basis-URL parallel und
|
||||||
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten: eine
|
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten:
|
||||||
* JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
* - eine JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
||||||
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"), oder ein
|
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"),
|
||||||
* Content-Type, der explizit auf JSON/GraphQL hindeutet. Reine HTML-Seiten
|
* - ein Content-Type, der explizit auf JSON/GraphQL/XML-API hindeutet,
|
||||||
* (z. B. eine 404-Fehlerseite des Frontends) zählen nicht.
|
* - oder ein "WWW-Authenticate"-Header (401 mit diesem Header ist ein sehr
|
||||||
|
* starkes Signal für eine authentifizierungspflichtige API, selbst wenn
|
||||||
|
* der Body selbst nur eine schlichte Textmeldung ist).
|
||||||
|
* Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine
|
||||||
|
* Login-Weiterleitung ohne API-Signal) zählen nicht.
|
||||||
*/
|
*/
|
||||||
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||||
const checks = await Promise.all(
|
const checks = await Promise.all(
|
||||||
@@ -83,15 +132,33 @@ export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
|||||||
if (!result || result.status === 0 || result.status === 404) return null;
|
if (!result || result.status === 0 || result.status === 404) return null;
|
||||||
|
|
||||||
const contentTypeIsApi =
|
const contentTypeIsApi =
|
||||||
result.contentType?.includes("json") || result.contentType?.includes("graphql");
|
result.contentType?.includes("json") ||
|
||||||
|
result.contentType?.includes("graphql") ||
|
||||||
|
result.contentType?.includes("xml");
|
||||||
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
||||||
|
const hasAuthChallenge = !!result.wwwAuthenticate;
|
||||||
|
|
||||||
if (!contentTypeIsApi && !bodyIsJson) return null;
|
if (!contentTypeIsApi && !bodyIsJson && !hasAuthChallenge) return null;
|
||||||
|
|
||||||
const detected: DetectedApi = { path, type, status: result.status };
|
const detected: DetectedApi = { path, type, status: result.status };
|
||||||
return detected;
|
return detected;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return checks.filter((c): c is DetectedApi => c !== null);
|
const matches = checks.filter((c): c is DetectedApi => c !== null);
|
||||||
|
|
||||||
|
// Mehrere Kandidaten-Einträge können denselben Pfad haben (z. B. das
|
||||||
|
// generische "/api/status" -> "REST-API" UND das Portainer-spezifische
|
||||||
|
// "/api/status" -> "Portainer API") - nach Pfad deduplizieren, dabei die
|
||||||
|
// spezifischere (nicht-generische) Beschriftung bevorzugen.
|
||||||
|
const GENERIC_TYPES = new Set(["REST-API", "JSON-RPC", "Health-Endpunkt"]);
|
||||||
|
const byPath = new Map<string, DetectedApi>();
|
||||||
|
for (const match of matches) {
|
||||||
|
const existing = byPath.get(match.path);
|
||||||
|
if (!existing || (GENERIC_TYPES.has(existing.type) && !GENERIC_TYPES.has(match.type))) {
|
||||||
|
byPath.set(match.path, match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byPath.values());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
|
|||||||
import { probeHttp } from "./http.js";
|
import { probeHttp } from "./http.js";
|
||||||
import { detectSoftware } from "./softwareDetection.js";
|
import { detectSoftware } from "./softwareDetection.js";
|
||||||
import { findBestIconMatch } from "./iconDb.js";
|
import { findBestIconMatch } from "./iconDb.js";
|
||||||
import { pingHost, isPingBinaryConfirmedMissing } from "./ping.js";
|
|
||||||
|
|
||||||
export interface ScanTarget {
|
export interface ScanTarget {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@@ -91,18 +90,6 @@ export async function scanDeviceServices(
|
|||||||
// gemeldeten Namen ein.
|
// gemeldeten Namen ein.
|
||||||
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
||||||
|
|
||||||
// Vor dem eigentlichen Portscan erst ein einzelner Ping (ICMP) - ist das
|
|
||||||
// Gerät gar nicht erreichbar (aus, im Standby, vom Netz getrennt), spart
|
|
||||||
// das den kompletten Portscan (auch parallel noch ~800ms) UND alle
|
|
||||||
// nachfolgenden DNS/HTTP-Versuche. Nur wenn KEIN Ping ankommt wird
|
|
||||||
// übersprungen - manche Geräte blocken ICMP, antworten aber auf TCP, dafür
|
|
||||||
// bleibt genau deswegen bewusst KEIN weiterer früher Abbruch bestehen,
|
|
||||||
// sondern nur dieser eine zusätzliche, sehr schnelle Vorab-Check.
|
|
||||||
const reachable = await pingHost(device.ip);
|
|
||||||
if (!reachable && !isPingBinaryConfirmedMissing()) {
|
|
||||||
return { services: [], suggestedHostname };
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
||||||
|
|
||||||
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei
|
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ServicesPage } from "./routes/admin/ServicesPage.js";
|
|||||||
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
|
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
|
||||||
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
|
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
|
||||||
import { ScannerPage } from "./routes/admin/ScannerPage.js";
|
import { ScannerPage } from "./routes/admin/ScannerPage.js";
|
||||||
|
import { ApisPage } from "./routes/admin/ApisPage.js";
|
||||||
import { ReadLaterPage } from "./routes/admin/ReadLaterPage.js";
|
import { ReadLaterPage } from "./routes/admin/ReadLaterPage.js";
|
||||||
import { PluginsPage } from "./routes/admin/PluginsPage.js";
|
import { PluginsPage } from "./routes/admin/PluginsPage.js";
|
||||||
import { SettingsPage } from "./routes/admin/SettingsPage.js";
|
import { SettingsPage } from "./routes/admin/SettingsPage.js";
|
||||||
@@ -80,6 +81,12 @@ const adminScannerRoute = createRoute({
|
|||||||
component: ScannerPage,
|
component: ScannerPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adminApisRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/apis",
|
||||||
|
component: ApisPage,
|
||||||
|
});
|
||||||
|
|
||||||
const adminReadLaterRoute = createRoute({
|
const adminReadLaterRoute = createRoute({
|
||||||
getParentRoute: () => adminRoute,
|
getParentRoute: () => adminRoute,
|
||||||
path: "/read-later",
|
path: "/read-later",
|
||||||
@@ -115,6 +122,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
adminBookmarksRoute,
|
adminBookmarksRoute,
|
||||||
adminCategoriesRoute,
|
adminCategoriesRoute,
|
||||||
adminScannerRoute,
|
adminScannerRoute,
|
||||||
|
adminApisRoute,
|
||||||
adminReadLaterRoute,
|
adminReadLaterRoute,
|
||||||
adminPluginsRoute,
|
adminPluginsRoute,
|
||||||
adminSettingsRoute,
|
adminSettingsRoute,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
faBookmark,
|
faBookmark,
|
||||||
faThumbtack,
|
faThumbtack,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
|
faPlug,
|
||||||
faTag,
|
faTag,
|
||||||
faPuzzlePiece,
|
faPuzzlePiece,
|
||||||
faGear,
|
faGear,
|
||||||
@@ -29,6 +30,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [
|
|||||||
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: faBookmark },
|
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: faBookmark },
|
||||||
{ to: "/admin/read-later", label: "Später lesen", icon: faThumbtack },
|
{ to: "/admin/read-later", label: "Später lesen", icon: faThumbtack },
|
||||||
{ to: "/admin/scanner", label: "Scanner", icon: faMagnifyingGlass },
|
{ to: "/admin/scanner", label: "Scanner", icon: faMagnifyingGlass },
|
||||||
|
{ to: "/admin/apis", label: "APIs", icon: faPlug },
|
||||||
{ to: "/admin/categories", label: "Kategorien", icon: faTag },
|
{ to: "/admin/categories", label: "Kategorien", icon: faTag },
|
||||||
{ to: "/admin/plugins", label: "Plugins", icon: faPuzzlePiece },
|
{ to: "/admin/plugins", label: "Plugins", icon: faPuzzlePiece },
|
||||||
{ to: "/admin/settings", label: "Einstellungen", icon: faGear },
|
{ to: "/admin/settings", label: "Einstellungen", icon: faGear },
|
||||||
|
|||||||
78
apps/frontend/src/routes/admin/ApisPage.tsx
Normal file
78
apps/frontend/src/routes/admin/ApisPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useServices } from "../../hooks/useServices.js";
|
||||||
|
import { useDetectedApis, type DetectedApiEntry } from "../../hooks/useDetectedApis.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApisPage() {
|
||||||
|
const { data: services } = useServices();
|
||||||
|
const { data: apis, isLoading, isError } = useDetectedApis();
|
||||||
|
|
||||||
|
const serviceById = new Map((services ?? []).map((s) => [s.id, s]));
|
||||||
|
const groups = new Map<string, DetectedApiEntry[]>();
|
||||||
|
for (const entry of apis ?? []) {
|
||||||
|
const list = groups.get(entry.serviceId) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
groups.set(entry.serviceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="APIs"
|
||||||
|
description='Von einem eigenen, separaten Scan gefundene API-Endpunkte bekannter Dienste (siehe Admin -> Scanner -> "APIs jetzt scannen"). Läuft nie automatisch mit.'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Konnte nicht geladen werden.</p>
|
||||||
|
) : groups.size === 0 ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine APIs gefunden. Auf der Scanner-Seite einmal „APIs jetzt scannen" ausführen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from(groups.entries()).map(([serviceId, entries]) => {
|
||||||
|
const service = serviceById.get(serviceId);
|
||||||
|
return (
|
||||||
|
<div key={serviceId} className="rounded-2xl border border-black/10 p-4 dark:border-white/10">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-black dark:text-white">
|
||||||
|
{service?.displayName ?? "Unbekannter Dienst"}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-black/30 dark:text-white/30">
|
||||||
|
zuletzt geprüft: {formatDate(entries[0].detectedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.id}>
|
||||||
|
<a
|
||||||
|
href={`${service?.url ?? ""}${e.path}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`HTTP ${e.status}`}
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-black/5 px-2.5 py-1 text-xs
|
||||||
|
text-black/70 hover:bg-black/10 dark:bg-white/10 dark:text-white/70
|
||||||
|
dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{e.type} · {e.path}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBan, faCheck, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
|
import { faBan, faCheck, faTrash, faXmark } 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";
|
||||||
import { useServices } from "../../hooks/useServices.js";
|
import type { DetectedApiEntry } from "../../hooks/useDetectedApis.js";
|
||||||
import { useDetectedApis, type DetectedApiEntry } from "../../hooks/useDetectedApis.js";
|
|
||||||
import { usePersistedState } from "../../hooks/usePersistedState.js";
|
import { usePersistedState } from "../../hooks/usePersistedState.js";
|
||||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
@@ -329,40 +329,46 @@ function StaleDevicesReview({
|
|||||||
|
|
||||||
function ApiScannerCard() {
|
function ApiScannerCard() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: services } = useServices();
|
|
||||||
const { data: detectedApis } = useDetectedApis();
|
|
||||||
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
|
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
|
||||||
|
const [lastNewFindings, setLastNewFindings] = usePersistedState<
|
||||||
|
{ serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[]
|
||||||
|
>("scanner:apiNewFindings", []);
|
||||||
|
|
||||||
const scanMutation = useMutation({
|
const scanMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch("/api/scan/apis", { method: "POST" });
|
const res = await fetch("/api/scan/apis", { method: "POST" });
|
||||||
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`);
|
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`);
|
||||||
return res.json() as Promise<{ checked: number; servicesWithApi: number; totalFound: number }>;
|
return res.json() as Promise<{
|
||||||
|
checked: number;
|
||||||
|
servicesWithApi: number;
|
||||||
|
totalFound: number;
|
||||||
|
newFindings: { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[];
|
||||||
|
}>;
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
const newCount = result.newFindings.reduce((sum, f) => sum + f.apis.length, 0);
|
||||||
setStatus(
|
setStatus(
|
||||||
`Fertig: ${result.checked} Dienst(e) geprüft, bei ${result.servicesWithApi} Dienst(en) ${result.totalFound} API-Endpunkt(e) gefunden.`
|
`Fertig: ${result.checked} Dienst(e) geprüft, ${result.totalFound} API-Endpunkt(e) insgesamt bei ${result.servicesWithApi} Dienst(en)` +
|
||||||
|
(newCount > 0 ? `, davon ${newCount} neu/geändert seit dem letzten Scan.` : ", keine Änderungen seit dem letzten Scan.")
|
||||||
);
|
);
|
||||||
|
setLastNewFindings(result.newFindings);
|
||||||
queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
|
queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => setStatus(err.message),
|
onError: (err: Error) => setStatus(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const serviceById = new Map((services ?? []).map((s) => [s.id, s]));
|
|
||||||
const groups = new Map<string, DetectedApiEntry[]>();
|
|
||||||
for (const entry of detectedApis ?? []) {
|
|
||||||
const list = groups.get(entry.serviceId) ?? [];
|
|
||||||
list.push(entry);
|
|
||||||
groups.set(entry.serviceId, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2">
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2">
|
||||||
<h2 className="font-medium text-black dark:text-white">API-Scanner</h2>
|
<h2 className="font-medium text-black dark:text-white">API-Scanner</h2>
|
||||||
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
|
||||||
Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade
|
Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade
|
||||||
(OpenAPI/Swagger, GraphQL, REST) und listet die Funde auf. Läuft nur auf Knopfdruck,
|
(OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet
|
||||||
nie zusammen mit dem normalen Geräte-Scan.
|
neue/geänderte Funde hier auf. Läuft nur auf Knopfdruck, nie zusammen mit dem normalen
|
||||||
|
Geräte-Scan. Die vollständige, dauerhafte Liste steht immer unter{" "}
|
||||||
|
<Link to="/admin/apis" className="underline">
|
||||||
|
Admin → APIs
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -377,39 +383,24 @@ function ApiScannerCard() {
|
|||||||
</Button>
|
</Button>
|
||||||
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
||||||
|
|
||||||
{groups.size > 0 ? (
|
{lastNewFindings.length > 0 ? (
|
||||||
<ul className="mt-4 max-h-72 space-y-2 overflow-y-auto text-sm">
|
<ul className="mt-4 max-h-72 space-y-2 overflow-y-auto text-sm">
|
||||||
{Array.from(groups.entries()).map(([serviceId, entries]) => {
|
{lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
|
||||||
const service = serviceById.get(serviceId);
|
<li key={serviceId} className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2">
|
||||||
return (
|
<div className="font-medium text-black dark:text-white">{serviceName}</div>
|
||||||
<li key={serviceId} className="rounded-lg border border-black/5 p-2 dark:border-white/5">
|
|
||||||
<div className="font-medium text-black dark:text-white">
|
|
||||||
{service?.displayName ?? "Unbekannter Dienst"}
|
|
||||||
</div>
|
|
||||||
<ul className="mt-1 flex flex-wrap gap-2">
|
<ul className="mt-1 flex flex-wrap gap-2">
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<li key={e.id}>
|
<li key={e.id}>
|
||||||
<a
|
<span className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-black/60 dark:bg-white/10 dark:text-white/60">
|
||||||
href={`${service?.url ?? ""}${e.path}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-black/60
|
|
||||||
hover:bg-black/10 dark:bg-white/10 dark:text-white/60 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{e.type} · {e.path}
|
{e.type} · {e.path}
|
||||||
</a>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : null}
|
||||||
<p className="mt-4 text-sm text-black/30 dark:text-white/30">
|
|
||||||
Noch keine APIs gefunden. Einmal scannen, um loszulegen.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -483,7 +474,7 @@ async function scanAllDevices() {
|
|||||||
allStale.push(...result.staleServices);
|
allStale.push(...result.staleServices);
|
||||||
allNameChanges.push(...result.nameChanges);
|
allNameChanges.push(...result.nameChanges);
|
||||||
allNew.push(...result.newServices);
|
allNew.push(...result.newServices);
|
||||||
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
|
setBulkStatus(`Scanne ${device.hostname} … (${created} neue(r) Dienst(e) bisher)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") break;
|
if (err instanceof DOMException && err.name === "AbortError") break;
|
||||||
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||||
|
|||||||
@@ -328,6 +328,58 @@ function DangerZone() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SearchEngineHint() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between text-left"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-black dark:text-white">
|
||||||
|
💡 Als Browser-Suchmaschine einrichten
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 text-sm text-black/50 dark:text-white/50">
|
||||||
|
Tippe künftig z. B. <code className="rounded bg-black/5 px-1 dark:bg-white/10">lp proxmox</code>{" "}
|
||||||
|
direkt in die Adresszeile deines Browsers.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
<span className="text-black/30 dark:text-white/30">{open ? "▾" : "▸"}</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="mt-3 space-y-3 text-sm text-black/70 dark:text-white/70">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-black dark:text-white">Chrome / Edge</p>
|
||||||
|
<ol className="ml-4 list-decimal space-y-0.5">
|
||||||
|
<li>Einstellungen → „Suchmaschine" → „Suchmaschinen verwalten"</li>
|
||||||
|
<li>„Website-Suchmaschine hinzufügen"</li>
|
||||||
|
<li>
|
||||||
|
Name: <code className="rounded bg-black/5 px-1 dark:bg-white/10">LaunchPad</code>, Kürzel:{" "}
|
||||||
|
<code className="rounded bg-black/5 px-1 dark:bg-white/10">lp</code>, URL:{" "}
|
||||||
|
<code className="rounded bg-black/5 px-1 dark:bg-white/10">{origin}/search?q=%s</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-black dark:text-white">Firefox</p>
|
||||||
|
<ol className="ml-4 list-decimal space-y-0.5">
|
||||||
|
<li>Diese Seite einmal besucht (fertig, Firefox hat den Eintrag bereits erkannt)</li>
|
||||||
|
<li>Einstellungen → „Suche" → bei „LaunchPad" ein Schlagwort (z. B. „lp") eintragen</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-black/40 dark:text-white/40">
|
||||||
|
Danach: „lp <suchbegriff>" in die Adresszeile – bei eindeutigem Treffer geht's direkt
|
||||||
|
zum Dienst, sonst zur LaunchPad-Suche mit vorbefülltem Begriff.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { health, error } = useBackendHealth();
|
const { health, error } = useBackendHealth();
|
||||||
const [theme, toggleTheme] = useTheme();
|
const [theme, toggleTheme] = useTheme();
|
||||||
@@ -338,6 +390,8 @@ export function SettingsPage() {
|
|||||||
<AdminPageHeader title="Einstellungen" />
|
<AdminPageHeader title="Einstellungen" />
|
||||||
|
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<SearchEngineHint />
|
||||||
|
|
||||||
<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">Darstellung</h2>
|
<h2 className="mb-2 font-medium text-black dark:text-white">Darstellung</h2>
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user