Installer-Paket System: Backend speichert/served ZIP, Frontend Upload+Download, install.sh auto-download von Backend statt Git
This commit is contained in:
parent
c387e56816
commit
25a6cc2a1a
@ -68,6 +68,11 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("DELETE /api/v1/agents/{id}/request-update", h.cancelUpdate)
|
mux.HandleFunc("DELETE /api/v1/agents/{id}/request-update", h.cancelUpdate)
|
||||||
mux.HandleFunc("POST /api/v1/agents/request-update-all", h.requestUpdateAll)
|
mux.HandleFunc("POST /api/v1/agents/request-update-all", h.requestUpdateAll)
|
||||||
|
|
||||||
|
// Installer Routes
|
||||||
|
mux.HandleFunc("GET /api/v1/installer", h.getInstallerInfo)
|
||||||
|
mux.HandleFunc("POST /api/v1/installer/upload", h.uploadInstaller)
|
||||||
|
mux.HandleFunc("GET /api/v1/installer/download", h.downloadInstaller)
|
||||||
|
|
||||||
// WebSocket und Tunnel-Routes
|
// WebSocket und Tunnel-Routes
|
||||||
h.setupTunnelRoutes(mux, hub)
|
h.setupTunnelRoutes(mux, hub)
|
||||||
}
|
}
|
||||||
|
|||||||
106
backend/api/installer.go
Normal file
106
backend/api/installer.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/v1/installer/upload?platform=freebsd — Installer-Paket hochladen (ZIP oder Script)
|
||||||
|
func (h *Handler) uploadInstaller(w http.ResponseWriter, r *http.Request) {
|
||||||
|
platform := r.URL.Query().Get("platform")
|
||||||
|
if platform == "" {
|
||||||
|
platform = "freebsd"
|
||||||
|
}
|
||||||
|
validPlatforms := map[string]bool{"freebsd": true, "linux": true}
|
||||||
|
if !validPlatforms[platform] {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Plattform (freebsd, linux)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max 50MB
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
|
||||||
|
|
||||||
|
// Multipart oder raw body
|
||||||
|
var data []byte
|
||||||
|
var filename string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "" && len(ct) > 19 && ct[:19] == "multipart/form-data" {
|
||||||
|
file, header, ferr := r.FormFile("file")
|
||||||
|
if ferr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Datei fehlt im Upload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err = io.ReadAll(file)
|
||||||
|
filename = header.Filename
|
||||||
|
} else {
|
||||||
|
data, err = io.ReadAll(r.Body)
|
||||||
|
filename = r.URL.Query().Get("filename")
|
||||||
|
if filename == "" {
|
||||||
|
filename = fmt.Sprintf("installer-%s.zip", platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Leere oder fehlerhafte Datei")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
hashStr := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
if err := h.db.SaveInstaller(platform, filename, hashStr, data); err != nil {
|
||||||
|
log.Printf("Installer speichern fehlgeschlagen: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Installer fuer %s hochgeladen: %s (%d bytes, SHA256: %s)", platform, filename, len(data), hashStr[:16])
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"platform": platform,
|
||||||
|
"filename": filename,
|
||||||
|
"sha256": hashStr,
|
||||||
|
"size": len(data),
|
||||||
|
"message": "Installer hochgeladen",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/installer — Installer-Info (alle Plattformen)
|
||||||
|
func (h *Handler) getInstallerInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
all, err := h.db.GetAllInstallerInfo()
|
||||||
|
if err != nil || len(all) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"installers": []interface{}{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"installers": all,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/installer/download?platform=freebsd — Installer herunterladen
|
||||||
|
func (h *Handler) downloadInstaller(w http.ResponseWriter, r *http.Request) {
|
||||||
|
platform := r.URL.Query().Get("platform")
|
||||||
|
if platform == "" {
|
||||||
|
platform = "freebsd"
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, hashStr, data, err := h.db.GetInstaller(platform)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, fmt.Sprintf("Kein Installer fuer Plattform '%s' vorhanden", platform))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
w.Header().Set("X-Installer-SHA256", hashStr)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
@ -191,6 +191,17 @@ func (d *Database) migrate() error {
|
|||||||
last_used TIMESTAMPTZ
|
last_used TIMESTAMPTZ
|
||||||
)`)
|
)`)
|
||||||
|
|
||||||
|
// Installer-Pakete (install.sh + Plugin als ZIP pro Plattform)
|
||||||
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS installers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
platform TEXT NOT NULL UNIQUE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`)
|
||||||
|
|
||||||
// Retention Policy (90 Tage)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
@ -604,6 +615,57 @@ func (d *Database) GetAllFirmwareInfo() ([]map[string]interface{}, error) {
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Installer ---
|
||||||
|
|
||||||
|
func (d *Database) SaveInstaller(platform, filename, sha256hash string, data []byte) error {
|
||||||
|
_, err := d.db.Exec(`
|
||||||
|
INSERT INTO installers (platform, filename, data, sha256, size, uploaded_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
ON CONFLICT (platform) DO UPDATE SET filename=$2, data=$3, sha256=$4, size=$5, uploaded_at=NOW()
|
||||||
|
`, platform, filename, data, sha256hash, len(data))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetInstaller(platform string) (string, string, []byte, error) {
|
||||||
|
var filename, sha256hash string
|
||||||
|
var data []byte
|
||||||
|
err := d.db.QueryRow("SELECT filename, sha256, data FROM installers WHERE platform = $1", platform).Scan(&filename, &sha256hash, &data)
|
||||||
|
return filename, sha256hash, data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetInstallerInfo(platform string) (string, string, int, string, error) {
|
||||||
|
var filename, sha256hash string
|
||||||
|
var size int
|
||||||
|
var uploadedAt time.Time
|
||||||
|
err := d.db.QueryRow("SELECT filename, sha256, size, uploaded_at FROM installers WHERE platform = $1", platform).Scan(&filename, &sha256hash, &size, &uploadedAt)
|
||||||
|
return filename, sha256hash, size, uploadedAt.Format(time.RFC3339), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetAllInstallerInfo() ([]map[string]interface{}, error) {
|
||||||
|
rows, err := d.db.Query("SELECT platform, filename, sha256, size, uploaded_at FROM installers ORDER BY platform")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var results []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var platform, filename, sha256hash string
|
||||||
|
var size int
|
||||||
|
var uploadedAt time.Time
|
||||||
|
if err := rows.Scan(&platform, &filename, &sha256hash, &size, &uploadedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, map[string]interface{}{
|
||||||
|
"platform": platform,
|
||||||
|
"filename": filename,
|
||||||
|
"sha256": sha256hash,
|
||||||
|
"size": size,
|
||||||
|
"uploaded_at": uploadedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) DeleteAgent(id string) (bool, error) {
|
func (d *Database) DeleteAgent(id string) (bool, error) {
|
||||||
res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id)
|
res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -272,6 +272,33 @@ class ApiClient {
|
|||||||
requestUpdateAll() {
|
requestUpdateAll() {
|
||||||
return this.post('/api/v1/agents/request-update-all')
|
return this.post('/api/v1/agents/request-update-all')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Installer
|
||||||
|
getInstallerInfo() {
|
||||||
|
return this.get('/api/v1/installer')
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadInstaller(platform, file) {
|
||||||
|
const headers = {}
|
||||||
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/installer/upload?platform=${encodeURIComponent(platform)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
this.setToken(null)
|
||||||
|
window.location.href = '/login'
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(err.error || 'Upload fehlgeschlagen')
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient()
|
export const api = new ApiClient()
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Copy, Check, Terminal, Monitor } from 'lucide-react'
|
|||||||
|
|
||||||
const BACKEND_URL = 'https://dsbmueller.spdns.org:8443'
|
const BACKEND_URL = 'https://dsbmueller.spdns.org:8443'
|
||||||
const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'
|
const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'
|
||||||
const INSTALL_SH_URL = 'https://git.cynfo.net/christian/rmm/raw/branch/main/opnsense-plugin/install.sh'
|
|
||||||
|
|
||||||
export default function InstallGuide({ agentName, platform: initialPlatform }) {
|
export default function InstallGuide({ agentName, platform: initialPlatform }) {
|
||||||
const [platform, setPlatform] = useState(initialPlatform || 'freebsd')
|
const [platform, setPlatform] = useState(initialPlatform || 'freebsd')
|
||||||
@ -49,7 +48,9 @@ export default function InstallGuide({ agentName, platform: initialPlatform }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const opnInstallCmd = `fetch -o /tmp/install.sh "${INSTALL_SH_URL}" && sh /tmp/install.sh`
|
const opnInstallCmd = `fetch --no-verify-peer --no-verify-hostname -o /tmp/installer.zip \\
|
||||||
|
"${BACKEND_URL}/api/v1/installer/download?platform=freebsd&api_key=${API_KEY}" \\
|
||||||
|
&& cd /tmp && unzip -o installer.zip && sh /tmp/install.sh`
|
||||||
|
|
||||||
const linuxInstallCmd = `#!/bin/sh
|
const linuxInstallCmd = `#!/bin/sh
|
||||||
# RMM Agent + Updater Installation
|
# RMM Agent + Updater Installation
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { Upload, RefreshCw, CheckCircle, AlertTriangle, ArrowUpCircle, X, Monitor, Server, Cpu } from 'lucide-react'
|
import { Upload, RefreshCw, CheckCircle, AlertTriangle, ArrowUpCircle, X, Monitor, Server, Cpu, Package, Copy, Check } from 'lucide-react'
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
{ id: 'freebsd', label: 'FreeBSD / OPNsense', icon: Server },
|
{ id: 'freebsd', label: 'FreeBSD / OPNsense', icon: Server },
|
||||||
@ -8,25 +8,34 @@ const PLATFORMS = [
|
|||||||
{ id: 'windows', label: 'Windows', icon: Cpu },
|
{ id: 'windows', label: 'Windows', icon: Cpu },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const BACKEND_URL = 'https://dsbmueller.spdns.org:8443'
|
||||||
|
|
||||||
export default function Firmware() {
|
export default function Firmware() {
|
||||||
const [firmwareData, setFirmwareData] = useState(null)
|
const [firmwareData, setFirmwareData] = useState(null)
|
||||||
|
const [installerData, setInstallerData] = useState(null)
|
||||||
const [agents, setAgents] = useState([])
|
const [agents, setAgents] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [installerUploading, setInstallerUploading] = useState(false)
|
||||||
const [version, setVersion] = useState('')
|
const [version, setVersion] = useState('')
|
||||||
const [platform, setPlatform] = useState('freebsd')
|
const [platform, setPlatform] = useState('freebsd')
|
||||||
|
const [installerPlatform, setInstallerPlatform] = useState('freebsd')
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const [msgType, setMsgType] = useState('info')
|
const [msgType, setMsgType] = useState('info')
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
|
const installerFileRef = useRef(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [fw, ag] = await Promise.all([
|
const [fw, ag, inst] = await Promise.all([
|
||||||
api.getFirmwareInfo().catch(() => null),
|
api.getFirmwareInfo().catch(() => null),
|
||||||
api.getAgents().catch(() => []),
|
api.getAgents().catch(() => []),
|
||||||
|
api.getInstallerInfo().catch(() => null),
|
||||||
])
|
])
|
||||||
setFirmwareData(fw)
|
setFirmwareData(fw)
|
||||||
setAgents(Array.isArray(ag) ? ag : [])
|
setAgents(Array.isArray(ag) ? ag : [])
|
||||||
|
setInstallerData(inst)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -202,6 +211,92 @@ export default function Firmware() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Installer Packages */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Installer-Pakete</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
ZIP-Pakete mit install.sh und Plugin-Dateien. Werden auf neuen Geraeten per fetch heruntergeladen und ausgefuehrt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Existing installers */}
|
||||||
|
{installerData?.installers?.length > 0 && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{installerData.installers.map(inst => (
|
||||||
|
<div key={inst.platform} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-orange-400" />
|
||||||
|
<span className="text-white text-sm font-medium">{inst.platform === 'linux' ? 'Linux' : 'OPNsense / FreeBSD'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{inst.filename} — {formatSize(inst.size)} — {new Date(inst.uploaded_at).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-600 font-mono mt-0.5">{inst.sha256?.substring(0, 32)}...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Install command */}
|
||||||
|
{installerData?.installers?.some(i => i.platform === 'freebsd') && (
|
||||||
|
<div className="mb-4 bg-gray-950 rounded p-3 relative">
|
||||||
|
<div className="text-[10px] text-gray-500 mb-1">Installationsbefehl (OPNsense):</div>
|
||||||
|
<pre className="text-xs font-mono text-orange-300 whitespace-pre-wrap">{`fetch --no-verify-peer --no-verify-hostname -o /tmp/installer.zip "${BACKEND_URL}/api/v1/installer/download?platform=freebsd&api_key=01532e5a7c9e70bf2757df77a2f5b9b9" && cd /tmp && unzip -o installer.zip && sh /tmp/install.sh`}</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(`fetch --no-verify-peer --no-verify-hostname -o /tmp/installer.zip "${BACKEND_URL}/api/v1/installer/download?platform=freebsd&api_key=01532e5a7c9e70bf2757df77a2f5b9b9" && cd /tmp && unzip -o installer.zip && sh /tmp/install.sh`)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 text-gray-600 hover:text-orange-400 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
|
<select
|
||||||
|
value={installerPlatform}
|
||||||
|
onChange={(e) => setInstallerPlatform(e.target.value)}
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:border-orange-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="freebsd">OPNsense / FreeBSD</option>
|
||||||
|
<option value="linux">Linux</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={installerFileRef}
|
||||||
|
accept=".zip,.sh"
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-400 file:mr-3 file:bg-gray-700 file:text-gray-300 file:border-0 file:rounded file:px-3 file:py-1 file:text-xs file:cursor-pointer flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const file = installerFileRef.current?.files?.[0]
|
||||||
|
if (!file) return showMsg('Bitte Datei auswaehlen', 'error')
|
||||||
|
setInstallerUploading(true)
|
||||||
|
try {
|
||||||
|
const result = await api.uploadInstaller(installerPlatform, file)
|
||||||
|
showMsg(`Installer fuer ${installerPlatform} hochgeladen (${formatSize(result.size)})`, 'success')
|
||||||
|
installerFileRef.current.value = ''
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
showMsg(`Upload fehlgeschlagen: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setInstallerUploading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={installerUploading}
|
||||||
|
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{installerUploading ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
{installerUploading ? 'Hochladen...' : 'Hochladen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Agent List */}
|
{/* Agent List */}
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user