feat: Phase 7 — MeshCentral Remote Desktop Integration

Backend:
- MeshCentralOptions + MeshCentralService: Node-Lookup via Hostname, Remote-Desktop-URL-Generierung
- RemoteDesktopController: GET /api/v1/agents/{id}/remote-session mit 3 Status-Zuständen (nicht konfiguriert / Agent fehlt / bereit)
- Program.cs: HttpClient + MeshCentralService registriert, appsettings.json mit Konfigurationsblock

Go Agent:
- config.go: MeshCentralUrl + MeshEnabled Felder
- internal/meshagent/installer.go: MeshAgent Download + Installation (Windows Service / Linux systemd)
- main.go: Automatische MeshAgent-Installation nach Enrollment wenn aktiviert

Frontend:
- RemoteDesktopButton: Modales Dialog mit 3 Zustandsanzeigen (Setup nötig / Agent installieren / Remote Desktop öffnen)
- AgentDetailPage: RemoteDesktopButton im Header integriert
- api/types.ts + api/client.ts: RemoteSessionInfo Typ + remoteDesktopApi

docker-compose.yml: MeshCentral Service (ghcr.io/ylianst/meshcentral:latest, Ports 4430/4431)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-19 14:39:49 +01:00
parent 84629dfbcf
commit 55e016c07d
14 changed files with 579 additions and 7 deletions

View File

@@ -15,6 +15,7 @@ import (
"nexusrmm.local/agent/internal/connection"
"nexusrmm.local/agent/internal/deployer"
"nexusrmm.local/agent/internal/executor"
"nexusrmm.local/agent/internal/meshagent"
pb "nexusrmm.local/agent/pkg/proto"
)
@@ -62,6 +63,14 @@ func main() {
log.Printf("Enrolled with ID: %s", cfg.AgentID)
}
// MeshAgent installieren falls konfiguriert
if cfg.MeshEnabled && cfg.MeshCentralUrl != "" {
log.Printf("Installiere MeshAgent von %s...", cfg.MeshCentralUrl)
if err := meshagent.Install(context.Background(), cfg.MeshCentralUrl); err != nil {
log.Printf("MeshAgent-Installation fehlgeschlagen (nicht kritisch): %v", err)
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)

View File

@@ -11,6 +11,8 @@ type Config struct {
AgentID string `yaml:"agent_id"`
HeartbeatInterval int `yaml:"heartbeat_interval"`
TLSEnabled bool `yaml:"tls_enabled"`
MeshCentralUrl string `yaml:"mesh_central_url"`
MeshEnabled bool `yaml:"mesh_enabled"`
}
func Load(path string) (*Config, error) {

View File

@@ -0,0 +1,149 @@
package meshagent
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// IsInstalled prüft ob der MeshAgent-Prozess läuft oder das Binary vorhanden ist.
func IsInstalled() bool {
if runtime.GOOS == "windows" {
// Prüfe ob MeshAgent Service läuft
cmd := exec.Command("sc", "query", "Mesh Agent")
return cmd.Run() == nil
}
// Linux: prüfe ob meshagent Prozess läuft
cmd := exec.Command("pgrep", "-x", "meshagent")
return cmd.Run() == nil
}
// Install lädt den MeshAgent von MeshCentral herunter und installiert ihn.
// meshCentralUrl: z.B. "https://192.168.1.100:4430"
func Install(ctx context.Context, meshCentralUrl string) error {
if IsInstalled() {
log.Println("MeshAgent ist bereits installiert")
return nil
}
log.Printf("MeshAgent wird von %s heruntergeladen...", meshCentralUrl)
// Agent-ID je nach OS
agentID := "6" // Linux x64
if runtime.GOOS == "windows" {
agentID = "3" // Windows x64
}
downloadUrl := fmt.Sprintf("%s/meshagents?id=%s", strings.TrimRight(meshCentralUrl, "/"), agentID)
// SSL-Fehler ignorieren (selbstsigniertes Zertifikat in Dev)
httpClient := &http.Client{
Timeout: 5 * time.Minute,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
if err != nil {
return fmt.Errorf("HTTP Request konnte nicht erstellt werden: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("Download fehlgeschlagen: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("MeshCentral antwortete mit Status %d — ist MeshCentral gestartet?", resp.StatusCode)
}
// Temp-Datei speichern
var tmpPath string
if runtime.GOOS == "windows" {
tmpPath = filepath.Join(os.TempDir(), "meshagent.exe")
} else {
tmpPath = filepath.Join(os.TempDir(), "meshagent")
}
f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("Temp-Datei konnte nicht erstellt werden: %w", err)
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
return fmt.Errorf("Fehler beim Schreiben: %w", err)
}
f.Close()
log.Printf("MeshAgent heruntergeladen nach %s", tmpPath)
// Installieren
return installBinary(ctx, tmpPath, meshCentralUrl)
}
func installBinary(ctx context.Context, binaryPath, meshCentralUrl string) error {
if runtime.GOOS == "windows" {
// Windows: als Service installieren
cmd := exec.CommandContext(ctx, binaryPath, "-install", "-url", meshCentralUrl)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("MeshAgent Windows-Installation fehlgeschlagen: %w", err)
}
log.Println("MeshAgent als Windows Service installiert")
} else {
// Linux: in /usr/local/bin installieren und als Service starten
installPath := "/usr/local/bin/meshagent"
if err := os.Rename(binaryPath, installPath); err != nil {
// Falls rename scheitert (cross-device), kopieren
if err2 := copyFile(binaryPath, installPath); err2 != nil {
return fmt.Errorf("MeshAgent konnte nicht nach %s verschoben werden: %w", installPath, err2)
}
}
if err := os.Chmod(installPath, 0755); err != nil {
return fmt.Errorf("chmod fehlgeschlagen: %w", err)
}
// Als Service starten (systemd oder direkt)
cmd := exec.CommandContext(ctx, installPath, "-install", "-url", meshCentralUrl)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
// Direkt starten als Fallback
log.Printf("Service-Installation fehlgeschlagen, starte direkt: %v", err)
go func() {
exec.Command(installPath, "-url", meshCentralUrl).Run()
}()
}
log.Println("MeshAgent auf Linux installiert")
}
return nil
}
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}

View File

@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NexusRMM.Api.Services;
using NexusRMM.Infrastructure.Data;
namespace NexusRMM.Api.Controllers;
[ApiController]
[Route("api/v1/agents/{agentId:guid}/remote-session")]
public class RemoteDesktopController : ControllerBase
{
private readonly RmmDbContext _db;
private readonly MeshCentralService _meshCentral;
public RemoteDesktopController(RmmDbContext db, MeshCentralService meshCentral)
{
_db = db;
_meshCentral = meshCentral;
}
/// <summary>
/// Gibt Remote-Desktop-Informationen für einen Agent zurück.
/// Falls MeshCentral nicht konfiguriert ist, wird configured=false zurückgegeben.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetRemoteSession(Guid agentId)
{
var agent = await _db.Agents.FindAsync(agentId);
if (agent is null) return NotFound();
if (!_meshCentral.IsEnabled)
{
return Ok(new
{
configured = false,
message = "MeshCentral ist nicht konfiguriert. Setze 'MeshCentral:Enabled=true' in appsettings.json.",
setupUrl = "https://localhost:4430",
});
}
if (string.IsNullOrEmpty(agent.MeshAgentId))
{
// Versuche Node zu finden
var nodeId = await _meshCentral.FindNodeByHostnameAsync(agent.Hostname);
if (nodeId is not null)
{
agent.MeshAgentId = nodeId;
await _db.SaveChangesAsync();
}
}
if (string.IsNullOrEmpty(agent.MeshAgentId))
{
return Ok(new
{
configured = true,
agentInstalled = false,
message = $"MeshAgent nicht auf '{agent.Hostname}' gefunden. Agent muss MeshAgent installieren.",
meshAgentDownloadUrl = _meshCentral.GetMeshAgentDownloadUrl(agent.OsType.ToString()),
});
}
return Ok(new
{
configured = true,
agentInstalled = true,
meshNodeId = agent.MeshAgentId,
sessionUrl = _meshCentral.GetRemoteDesktopUrl(agent.MeshAgentId),
meshCentralBaseUrl = "https://localhost:4430",
});
}
}

View File

@@ -25,6 +25,20 @@ builder.Services.AddSwaggerGen();
builder.Services.AddScoped<AlertEvaluationService>();
// MeshCentral Konfiguration
builder.Services.Configure<MeshCentralOptions>(
builder.Configuration.GetSection(MeshCentralOptions.SectionName));
// HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung)
builder.Services.AddHttpClient("MeshCentral")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true, // Dev: SSL bypass
UseCookies = false,
});
builder.Services.AddScoped<MeshCentralService>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>

View File

@@ -0,0 +1,11 @@
namespace NexusRMM.Api.Services;
public class MeshCentralOptions
{
public const string SectionName = "MeshCentral";
public string BaseUrl { get; set; } = "https://localhost:4430";
public string Username { get; set; } = "admin";
public string Password { get; set; } = "admin";
public bool IgnoreSslErrors { get; set; } = true;
public bool Enabled { get; set; } = false;
}

View File

@@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace NexusRMM.Api.Services;
/// <summary>
/// HTTP-Client für die MeshCentral REST API.
/// Authentifizierung via Cookie-Session, dann Node-Lookup und Remote-Session-URL-Generierung.
/// </summary>
public class MeshCentralService
{
private readonly MeshCentralOptions _options;
private readonly ILogger<MeshCentralService> _logger;
private readonly HttpClient _httpClient;
public MeshCentralService(
IOptions<MeshCentralOptions> options,
ILogger<MeshCentralService> logger,
IHttpClientFactory httpClientFactory)
{
_options = options.Value;
_logger = logger;
_httpClient = httpClientFactory.CreateClient("MeshCentral");
}
public bool IsEnabled => _options.Enabled;
/// <summary>
/// Gibt die Remote-Desktop-URL für einen MeshCentral-Node zurück.
/// Öffnet MeshCentral auf der Geräteseite — User muss im Browser eingeloggt sein.
/// </summary>
public string GetRemoteDesktopUrl(string meshNodeId)
{
// URL-Format für direkte Geräteansicht in MeshCentral
// viewmode=11 = Remote Desktop, hide=31 = UI Elemente verstecken
return $"{_options.BaseUrl.TrimEnd('/')}/?viewmode=11&hide=31#{Uri.EscapeDataString(meshNodeId)}";
}
/// <summary>
/// Sucht einen MeshCentral-Node anhand des Hostnamens.
/// Gibt die Node-ID zurück oder null wenn nicht gefunden.
/// </summary>
public async Task<string?> FindNodeByHostnameAsync(string hostname)
{
if (!_options.Enabled) return null;
try
{
var loginCookie = await LoginAsync();
if (loginCookie is null) return null;
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{_options.BaseUrl}/api/v1/devices");
request.Headers.Add("Cookie", loginCookie);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
// MeshCentral API gibt { devices: { nodeId: { name: "...", ... } } } zurück
if (doc.RootElement.TryGetProperty("devices", out var devices))
{
foreach (var device in devices.EnumerateObject())
{
if (device.Value.TryGetProperty("name", out var name) &&
name.GetString()?.Equals(hostname, StringComparison.OrdinalIgnoreCase) == true)
{
return device.Name; // Node-ID
}
}
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MeshCentral Node-Lookup für {Hostname} fehlgeschlagen", hostname);
return null;
}
}
/// <summary>
/// Gibt die Download-URL für den MeshAgent zurück.
/// Windows x64: id=3, Linux x64: id=6
/// </summary>
public string GetMeshAgentDownloadUrl(string osType)
{
var agentId = osType.ToLower() == "windows" ? 3 : 6;
return $"{_options.BaseUrl.TrimEnd('/')}/meshagents?id={agentId}";
}
private async Task<string?> LoginAsync()
{
try
{
var loginData = new { username = _options.Username, password = _options.Password };
var json = JsonSerializer.Serialize(loginData);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(
$"{_options.BaseUrl}/api/v1/authToken", content);
// MeshCentral gibt Set-Cookie zurück
if (response.Headers.TryGetValues("Set-Cookie", out var cookies))
{
var sessionCookie = cookies.FirstOrDefault(c => c.StartsWith("meshcentral.sid"));
if (sessionCookie is not null)
return sessionCookie.Split(';')[0]; // Nur Name=Wert Teil
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MeshCentral Login fehlgeschlagen");
return null;
}
}
}

View File

@@ -5,6 +5,13 @@
"Cors": {
"Origins": ["http://localhost:5173"]
},
"MeshCentral": {
"BaseUrl": "https://localhost:4430",
"Username": "admin",
"Password": "admin",
"IgnoreSslErrors": true,
"Enabled": false
},
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@@ -74,7 +74,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("MacAddress");
b.ToTable("Agents");
b.ToTable("Agents", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>
@@ -100,7 +100,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("Timestamp");
b.ToTable("AgentMetrics");
b.ToTable("AgentMetrics", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.Alert", b =>
@@ -138,7 +138,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("RuleId");
b.ToTable("Alerts");
b.ToTable("Alerts", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.AlertRule", b =>
@@ -172,7 +172,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasKey("Id");
b.ToTable("AlertRules");
b.ToTable("AlertRules", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.SoftwarePackage", b =>
@@ -219,7 +219,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("Name", "Version", "OsType")
.IsUnique();
b.ToTable("SoftwarePackages");
b.ToTable("SoftwarePackages", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.TaskItem", b =>
@@ -253,7 +253,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("AgentId");
b.ToTable("Tasks");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.Ticket", b =>
@@ -291,7 +291,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("AgentId");
b.ToTable("Tickets");
b.ToTable("Tickets", (string)null);
});
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>

View File

@@ -14,6 +14,7 @@ import type {
CreateSoftwarePackageRequest,
DeployRequest,
DeployResponse,
RemoteSessionInfo,
} from './types'
const BASE_URL = '/api/v1'
@@ -97,3 +98,9 @@ export const deployApi = {
deploy: (data: DeployRequest) =>
request<DeployResponse>('/deploy', { method: 'POST', body: JSON.stringify(data) }),
}
// Remote Desktop
export const remoteDesktopApi = {
getSession: (agentId: string) =>
request<RemoteSessionInfo>(`/agents/${agentId}/remote-session`),
}

View File

@@ -173,3 +173,14 @@ export interface DeployResponse {
packageName: string
version: string
}
export interface RemoteSessionInfo {
configured: boolean
agentInstalled?: boolean
message?: string
setupUrl?: string
meshAgentDownloadUrl?: string
meshNodeId?: string
sessionUrl?: string
meshCentralBaseUrl?: string
}

View File

@@ -0,0 +1,147 @@
import { useState } from 'react'
import { Monitor, Loader2, AlertTriangle, ExternalLink } from 'lucide-react'
import { remoteDesktopApi } from '../api/client'
import type { RemoteSessionInfo } from '../api/types'
import { cn } from '../lib/utils'
interface RemoteDesktopButtonProps {
agentId: string
agentHostname: string
className?: string
}
export function RemoteDesktopButton({ agentId, agentHostname, className }: RemoteDesktopButtonProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sessionInfo, setSessionInfo] = useState<RemoteSessionInfo | null>(null)
const [showModal, setShowModal] = useState(false)
async function handleClick() {
setLoading(true)
setError(null)
try {
const info = await remoteDesktopApi.getSession(agentId)
setSessionInfo(info)
setShowModal(true)
} catch (e) {
setError('Remote-Session konnte nicht geladen werden')
} finally {
setLoading(false)
}
}
function handleConnect() {
if (sessionInfo?.sessionUrl) {
window.open(sessionInfo.sessionUrl, `rmm-remote-${agentId}`,
'width=1280,height=800,scrollbars=no,toolbar=no,menubar=no')
}
}
return (
<>
<button
onClick={handleClick}
disabled={loading}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors',
'bg-purple-500/20 text-purple-400 border border-purple-500/30 hover:bg-purple-500/30',
loading && 'opacity-50 cursor-not-allowed',
className,
)}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Monitor size={16} />}
Remote Desktop
</button>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onClick={() => setShowModal(false)}>
<div className="bg-card border border-border rounded-xl p-6 w-full max-w-md shadow-2xl"
onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Monitor size={18} className="text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Remote Desktop</h3>
<p className="text-xs text-muted-foreground">{agentHostname}</p>
</div>
<button onClick={() => setShowModal(false)}
className="ml-auto text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
</div>
{/* Status: nicht konfiguriert */}
{sessionInfo && !sessionInfo.configured && (
<div className="space-y-3">
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertTriangle size={16} className="text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-yellow-300">{sessionInfo.message}</p>
</div>
<p className="text-sm text-muted-foreground">
MeshCentral läuft unter{' '}
<a href={sessionInfo.setupUrl} target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline">{sessionInfo.setupUrl}</a>.
Richte MeshCentral ein und setze <code className="text-xs bg-muted px-1 rounded">MeshCentral:Enabled=true</code> in der appsettings.json.
</p>
<button onClick={() => window.open(sessionInfo.setupUrl, '_blank')}
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded-lg text-sm hover:bg-yellow-500/30">
<ExternalLink size={14} />
MeshCentral öffnen
</button>
</div>
)}
{/* Status: Agent nicht installiert */}
{sessionInfo?.configured && !sessionInfo.agentInstalled && (
<div className="space-y-3">
<div className="flex items-start gap-2 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<AlertTriangle size={16} className="text-orange-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-orange-300">{sessionInfo.message}</p>
</div>
<p className="text-sm text-muted-foreground">
Der NexusRMM-Agent installiert MeshAgent automatisch wenn{' '}
<code className="text-xs bg-muted px-1 rounded">mesh_enabled: true</code> in der Agent-Config gesetzt ist.
</p>
{sessionInfo.meshAgentDownloadUrl && (
<button onClick={() => window.open(sessionInfo.meshAgentDownloadUrl, '_blank')}
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-muted border border-border rounded-lg text-sm hover:bg-accent">
<ExternalLink size={14} />
MeshAgent manuell herunterladen
</button>
)}
</div>
)}
{/* Status: bereit */}
{sessionInfo?.configured && sessionInfo.agentInstalled && (
<div className="space-y-3">
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<p className="text-sm text-green-400 font-medium"> MeshAgent verbunden</p>
<p className="text-xs text-muted-foreground mt-1">Node ID: {sessionInfo.meshNodeId}</p>
</div>
<p className="text-sm text-muted-foreground">
Remote Desktop öffnet sich in einem neuen Fenster. Falls du nach einem Login gefragt wirst, melde dich bei MeshCentral an.
</p>
<button onClick={handleConnect}
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-lg text-sm hover:bg-purple-500/30">
<Monitor size={14} />
Remote Desktop öffnen
</button>
<button onClick={() => window.open(sessionInfo.meshCentralBaseUrl, '_blank')}
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-muted border border-border rounded-lg text-sm hover:bg-accent text-xs">
<ExternalLink size={12} />
MeshCentral Dashboard
</button>
</div>
)}
{error && (
<p className="text-sm text-red-400 mt-2">{error}</p>
)}
</div>
</div>
)}
</>
)
}

View File

@@ -10,6 +10,7 @@ import {
AlertCircle,
CheckCircle,
} from 'lucide-react'
import { RemoteDesktopButton } from '../components/RemoteDesktopButton'
import {
LineChart,
Line,
@@ -170,6 +171,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
)}>
{signalRStatus === 'connected' ? 'Live' : signalRStatus === 'reconnecting' ? 'Verbindet...' : 'Offline'}
</span>
<RemoteDesktopButton agentId={agentId} agentHostname={agent.hostname} className="ml-4" />
</div>
</div>
</div>

View File

@@ -12,6 +12,23 @@ services:
- nexusrmm_pgdata:/var/lib/postgresql/data
restart: unless-stopped
meshcentral:
image: ghcr.io/ylianst/meshcentral:latest
container_name: nexusrmm-meshcentral
ports:
- "4430:4430"
- "4431:4431"
volumes:
- nexusrmm_meshdata:/opt/meshcentral/meshcentral-data
- nexusrmm_meshfiles:/opt/meshcentral/meshcentral-files
restart: unless-stopped
depends_on:
- nexusrmm-postgres
volumes:
nexusrmm_pgdata:
name: nexusrmm_pgdata
nexusrmm_meshdata:
name: nexusrmm_meshdata
nexusrmm_meshfiles:
name: nexusrmm_meshfiles