Files
meshcentral-workspace/wordpress/plugins/mesh-press/mesh-press.php
T
2026-07-11 10:44:42 -05:00

1844 lines
127 KiB
PHP

<?php
/**
* Plugin Name: GracePress: MeshPress Sync
* Plugin URI: https://christit.com
* Description: Enterprise Remote Monitoring & Management (RMM) bridge. Connects WordPress to the GracePress MeshPress Bridge for MeshCentral fleet syncing and client/device workflows.
* Version: 1.13.0
* Tested up to: 7.0
* Category: Utility
* Settings Slug: mesh-press
* Author: Jason Eugene Garrison
* Requires PHP: 8.0
* Icon: 🌐
*/
if (!defined('ABSPATH')) exit;
// =============================================================================
// 1. TELEMETRY PINGS & LIFECYCLE
// =============================================================================
if (!function_exists('gp_mesh_ping')) {
function gp_mesh_ping($a) {
if (get_option('gracepress_allow_pings', 'no') !== 'yes') return;
wp_remote_post('https://christit.com/gracepress/api.php', [
'blocking' => false,
'body' => wp_json_encode(['plugin' => 'mesh-press', 'action' => $a]),
'headers' => ['Content-Type' => 'application/json']
]);
}
}
register_activation_hook(__FILE__, function() { gp_mesh_ping('activate'); });
register_deactivation_hook(__FILE__, function() { gp_mesh_ping('deactivate'); });
if (!class_exists('GracePress_MeshPress')) {
class GracePress_MeshPress {
public function __construct() {
add_action('admin_menu', [$this, 'register_admin_page'], 120);
// AJAX Handlers
add_action('wp_ajax_gp_mesh_save_auth', [$this, 'ajax_save_auth']);
add_action('wp_ajax_gp_mesh_get_fleet', [$this, 'ajax_get_fleet']);
add_action('wp_ajax_gp_mesh_dispatch', [$this, 'ajax_dispatch_action']);
add_action('wp_ajax_gp_mesh_bridge_probe', [$this, 'ajax_bridge_probe']);
add_action('wp_ajax_gp_mesh_update_bridge', [$this, 'ajax_update_bridge']);
add_action('wp_ajax_gp_mesh_create_group', [$this, 'ajax_create_group']);
add_action('wp_ajax_gp_mesh_agent_links', [$this, 'ajax_agent_links']);
add_action('wp_ajax_gp_mesh_device_links', [$this, 'ajax_device_links']);
add_action('wp_ajax_gp_mesh_device_info', [$this, 'ajax_device_info']);
add_action('wp_ajax_gp_mesh_session_diagnostics', [$this, 'ajax_session_diagnostics']);
add_action('wp_ajax_gp_mesh_resolve_group', [$this, 'ajax_resolve_group']);
add_action('wp_ajax_gp_mesh_edit_group', [$this, 'ajax_edit_group']);
add_action('wp_ajax_gp_mesh_rename_device', [$this, 'ajax_rename_device']);
add_action('wp_ajax_gp_mesh_move_device', [$this, 'ajax_move_device']);
add_action('wp_ajax_gp_mesh_uninstall_agent', [$this, 'ajax_uninstall_agent']);
add_action('wp_ajax_gp_mesh_maintenance_probe', [$this, 'ajax_maintenance_probe']);
add_action('wp_ajax_gp_mesh_toggle_maintenance', [$this, 'ajax_toggle_maintenance']);
add_action('wp_ajax_gp_mesh_toggle_meshcentral_maintenance', [$this, 'ajax_toggle_meshcentral_maintenance']);
add_action('wp_ajax_gp_meshcentral_config_audit', [$this, 'ajax_meshcentral_config_audit']);
add_action('wp_ajax_gp_meshcentral_backup', [$this, 'ajax_meshcentral_backup']);
add_action('wp_ajax_gp_meshcentral_update', [$this, 'ajax_meshcentral_update']);
add_action('wp_ajax_gp_mesh_clear_cache', [$this, 'ajax_clear_cache']);
add_action('wp_ajax_gp_mesh_push_now', [$this, 'ajax_push_now']);
add_action('wp_ajax_gp_mesh_health_check', [$this, 'ajax_health_check']);
add_action('wp_ajax_gp_mesh_wait_enrollment', [$this, 'ajax_wait_enrollment']);
add_action('rest_api_init', [$this, 'register_rest_routes']);
// API Exporter for Service Desk (Decoupled Integration)
add_filter('gracepress_get_mesh_fleet', [$this, 'export_fleet_to_service_desk'], 10, 1);
}
// =====================================================================
// 2. DECOUPLED SERVICE DESK HOOK (Reads from Cache)
// =====================================================================
public function export_fleet_to_service_desk($empty_array) {
$fleet = get_transient('gp_mesh_fleet_cache');
if ($fleet && is_array($fleet)) return $fleet;
$nodes = get_transient('gp_mesh_nodes_cache');
if (!$nodes || !is_array($nodes)) return ['groups' => [], 'devices' => []];
return ['groups' => [], 'devices' => $nodes];
}
// =====================================================================
// 3. AJAX HANDLERS
// =====================================================================
public function ajax_save_auth() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error();
update_option('gp_mesh_host', rtrim(esc_url_raw($_POST['host']), '/'));
update_option('gp_mesh_key', sanitize_text_field($_POST['api_key']));
update_option('gp_mesh_public_url', rtrim(esc_url_raw($_POST['public_url'] ?? ''), '/'));
update_option('gp_mesh_mode', sanitize_key($_POST['mode'] ?? 'bridge'));
update_option('gp_mesh_link_mode', sanitize_key($_POST['link_mode'] ?? 'gotonode'));
update_option('gp_mesh_advanced_mode', !empty($_POST['advanced_mode']) && $_POST['advanced_mode'] !== '0' ? 'yes' : 'no');
if (function_exists('gp_log')) gp_log("MeshPress bridge configuration saved.", 'GP-MESH');
wp_send_json_success();
}
private function bridge_request($path, $args = []) {
$host = rtrim((string)get_option('gp_mesh_host', ''), '/');
$key = (string)get_option('gp_mesh_key', '');
if ($host === '') return new WP_Error('gp_mesh_missing_host', 'Bridge URL is missing.');
$headers = [];
if ($key !== '') $headers['Authorization'] = 'Bearer ' . $key;
return wp_remote_get($host . $path, array_merge(['timeout' => 20, 'headers' => $headers], $args));
}
private function bridge_post($path, $body = [], $args = []) {
$host = rtrim((string)get_option('gp_mesh_host', ''), '/');
$key = (string)get_option('gp_mesh_key', '');
if ($host === '') return new WP_Error('gp_mesh_missing_host', 'Bridge URL is missing.');
$headers = ['Content-Type' => 'application/json'];
if ($key !== '') $headers['Authorization'] = 'Bearer ' . $key;
return wp_remote_post($host . $path, array_merge([
'timeout' => 25,
'headers' => $headers,
'body' => wp_json_encode($body),
], $args));
}
private function bridge_json_response($response) {
if (is_wp_error($response)) {
return ['success' => false, 'error' => $response->get_error_message(), 'code' => 0];
}
$code = (int)wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (!is_array($data)) {
return ['success' => false, 'error' => 'Bridge returned invalid JSON.', 'code' => $code, 'raw' => substr($body, 0, 500)];
}
$data['code'] = $code;
if ($code >= 400 && !isset($data['success'])) $data['success'] = false;
return $data;
}
private function cache_clear() {
delete_transient('gp_mesh_fleet_cache');
delete_transient('gp_mesh_nodes_cache');
}
private function device_link($node_id, $mode) {
$public = rtrim((string)get_option('gp_mesh_public_url', 'https://mesh.christit.com'), '/');
if (!$public) $public = rtrim((string)get_option('gp_mesh_host', ''), '/');
if ($mode === 'console') $mode = 'terminal';
$view_modes = ['desktop' => '11', 'terminal' => '12', 'files' => '13'];
$link_mode = get_option('gp_mesh_link_mode', 'gotonode');
$node_param = $link_mode === 'node' ? 'node' : 'gotonode';
$link_node = $node_param === 'gotonode' ? basename(str_replace('\\', '/', (string)$node_id)) : (string)$node_id;
$encoded = rawurlencode($link_node);
if ($node_param === 'gotonode') $encoded = str_replace('%24', '$', $encoded);
return $public . '/?viewmode=' . rawurlencode($view_modes[$mode] ?? '11') . '&' . rawurlencode($node_param) . '=' . $encoded;
}
private function sanitize_mesh_id($value) {
return preg_replace('/[^A-Za-z0-9_\-\/\$:@.]/', '', (string)$value);
}
public function register_rest_routes() {
register_rest_route('gracepress/v1', '/meshpress/fleet', [
'methods' => 'POST',
'callback' => [$this, 'rest_receive_fleet'],
'permission_callback' => '__return_true'
]);
register_rest_route('gracepress/v1', '/meshpress/event', [
'methods' => 'POST',
'callback' => [$this, 'rest_receive_event'],
'permission_callback' => '__return_true'
]);
}
private function request_key($request) {
$header = (string)$request->get_header('authorization');
if (stripos($header, 'Bearer ') === 0) return trim(substr($header, 7));
return (string)$request->get_param('key');
}
public function rest_receive_fleet($request) {
$expected = (string)get_option('gp_mesh_key', '');
if ($expected === '' || !hash_equals($expected, $this->request_key($request))) {
return new WP_REST_Response(['success' => false, 'error' => 'Unauthorized'], 401);
}
$params = $request->get_json_params();
if (!is_array($params)) $params = [];
$groups = is_array($params['groups'] ?? null) ? $params['groups'] : [];
$devices = is_array($params['devices'] ?? null) ? $params['devices'] : [];
$fleet = [
'groups' => $groups,
'devices' => $devices,
'synced_at' => current_time('mysql'),
'source' => 'push',
'bridge_version' => sanitize_text_field($params['bridge_version'] ?? '')
];
set_transient('gp_mesh_fleet_cache', $fleet, DAY_IN_SECONDS);
set_transient('gp_mesh_nodes_cache', $devices, DAY_IN_SECONDS);
if (function_exists('gp_log')) gp_log("MeshPress Bridge pushed fleet snapshot.", 'GP-MESH', ['severity' => 'SUCCESS', 'devices' => count($devices), 'groups' => count($groups)]);
return ['success' => true, 'devices' => count($devices), 'groups' => count($groups)];
}
public function rest_receive_event($request) {
$expected = (string)get_option('gp_mesh_key', '');
if ($expected === '' || !hash_equals($expected, $this->request_key($request))) {
return new WP_REST_Response(['success' => false, 'error' => 'Unauthorized'], 401);
}
$params = $request->get_json_params();
if (!is_array($params)) $params = [];
$event = sanitize_key($params['event'] ?? 'bridge_event');
$severity = sanitize_key($params['severity'] ?? 'INFO');
$data = is_array($params['data'] ?? null) ? $params['data'] : [];
set_transient('gp_mesh_last_bridge_event', [
'event' => $event,
'severity' => strtoupper($severity),
'bridge_version' => sanitize_text_field($params['bridge_version'] ?? ''),
'data' => $data,
'received_at' => current_time('mysql')
], DAY_IN_SECONDS);
if (function_exists('gp_log')) {
gp_log('MeshPress Bridge event: ' . $event, 'GP-MESH', [
'severity' => strtoupper($severity),
'event' => $event,
'bridge_version' => sanitize_text_field($params['bridge_version'] ?? ''),
'data' => $data
]);
}
return ['success' => true, 'event' => $event];
}
private function render_fleet_table($nodes, $meshes) {
$mesh_map = [];
foreach((array)$meshes as $m) $mesh_map[$m['_id'] ?? $m['id'] ?? ''] = $m['name'] ?? 'Unknown';
$grouped = [];
foreach((array)$nodes as $n) {
if(!isset($n['name'])) continue;
$group_id = $n['groupId'] ?? $n['meshid'] ?? '';
$groupName = $n['groupName'] ?? ($mesh_map[$group_id] ?? 'Ungrouped');
if (!isset($grouped[$groupName])) $grouped[$groupName] = [];
$grouped[$groupName][] = $n;
}
ksort($grouped, SORT_NATURAL | SORT_FLAG_CASE);
$html = '<div class="gp-fleet-toolbar">';
$html .= '<div><strong>' . count((array)$nodes) . '</strong> devices across <strong>' . count($grouped) . '</strong> visible groups.</div>';
$html .= '<input class="gp-input" id="gp-fleet-search" placeholder="Filter devices, groups, or IP..." oninput="gpFilterFleet()">';
$html .= '<select class="gp-input" id="gp-fleet-status" onchange="gpFilterFleet()"><option value="">All statuses</option><option value="online">Online only</option><option value="offline">Offline only</option></select>';
$html .= '<select class="gp-input" id="gp-fleet-group" onchange="gpFilterFleet()"><option value="">All groups</option>';
foreach(array_keys($grouped) as $groupName) $html .= '<option value="' . esc_attr(strtolower($groupName)) . '">' . esc_html($groupName) . '</option>';
$html .= '</select></div>';
foreach($grouped as $groupName => $devices) {
$online = 0;
foreach($devices as $n) $online += ((isset($n['online']) ? (bool)$n['online'] : (isset($n['conn']) && $n['conn'] > 0)) ? 1 : 0);
$html .= '<section class="gp-fleet-group" data-group="' . esc_attr(strtolower($groupName)) . '">';
$html .= '<div class="gp-fleet-group-head"><div><strong>' . esc_html($groupName) . '</strong><span>' . count($devices) . ' devices, ' . $online . ' online</span></div><button class="gp-btn gp-btn-gray" type="button" onclick="this.closest(\'.gp-fleet-group\').classList.toggle(\'collapsed\')">Toggle</button></div>';
$html .= '<div class="gp-fleet-device-grid">';
foreach($devices as $n) {
$node_id = $n['id'] ?? $n['_id'] ?? '';
$isOnline = isset($n['online']) ? (bool)$n['online'] : (isset($n['conn']) && $n['conn'] > 0);
$statusTxt = $isOnline ? 'Online' : 'Offline';
$ip = $n['ip'] ?? $n['paddr'] ?? '---';
$search = strtolower(($n['name'] ?? '') . ' ' . $groupName . ' ' . $ip . ' ' . $statusTxt);
$safe_node = esc_attr($node_id);
$html .= '<article class="gp-device-card" data-search="' . esc_attr($search) . '" data-status="' . esc_attr(strtolower($statusTxt)) . '" data-group="' . esc_attr(strtolower($groupName)) . '">';
$html .= '<div class="gp-device-main"><span class="gp-status-dot" style="background:' . ($isOnline ? '#10b981' : '#64748b') . ';"></span><div><strong>' . esc_html($n['name']) . '</strong><small>' . esc_html($ip) . '</small></div></div>';
$html .= '<div class="gp-device-actions">';
if($node_id) {
$html .= '<button class="gp-btn gp-btn-green" onclick="gpOpenMeshSession(\''.$safe_node.'\', \'desktop\')">Desktop</button>';
$html .= '<button class="gp-btn gp-btn-blue" onclick="gpOpenMeshSession(\''.$safe_node.'\', \'console\')">Terminal</button>';
$html .= '<button class="gp-btn gp-btn-gray" onclick="gpOpenMeshSession(\''.$safe_node.'\', \'files\')">Files</button>';
$html .= '<button class="gp-btn gp-btn-gray" onclick="gpDiagnoseMeshSession(\''.$safe_node.'\')">Diagnose</button>';
}
$html .= '</div></article>';
}
$html .= '</div></section>';
}
return $html;
}
private function render_legacy_fleet_table($nodes, $meshes) {
$public = rtrim((string)get_option('gp_mesh_public_url', 'https://mesh.christit.com'), '/');
$mesh_map = [];
foreach((array)$meshes as $m) $mesh_map[$m['_id'] ?? $m['id'] ?? ''] = $m['name'] ?? 'Unknown';
$html = '<table class="gp-table"><tr><th>Status</th><th>Device Name</th><th>Group</th><th>IP Address</th><th>Actions</th></tr>';
foreach((array)$nodes as $n) {
if(!isset($n['name'])) continue;
$node_id = $n['id'] ?? $n['_id'] ?? '';
$isOnline = isset($n['online']) ? (bool)$n['online'] : (isset($n['conn']) && $n['conn'] > 0);
$group_id = $n['groupId'] ?? $n['meshid'] ?? '';
$groupName = $n['groupName'] ?? ($mesh_map[$group_id] ?? 'Unknown');
$ip = $n['ip'] ?? $n['paddr'] ?? '---';
$html .= '<tr>';
$html .= '<td><span class="gp-status-dot" style="background:'.($isOnline ? '#10b981' : '#cbd5e1').';"></span> <span style="font-size:11px;color:#64748b;font-weight:bold;">'.($isOnline ? 'Online' : 'Offline').'</span></td>';
$html .= '<td><strong>' . esc_html($n['name']) . '</strong></td>';
$html .= '<td><span class="gp-badge gp-badge-blue">' . esc_html($groupName) . '</span></td>';
$html .= '<td><code>' . esc_html($ip) . '</code></td>';
$html .= '<td style="display:flex; gap:5px; flex-wrap:wrap;">';
if($node_id && $public) {
$safe_node = esc_attr($node_id);
$html .= '<button class="gp-btn gp-btn-green" onclick="gpOpenMeshSession(\''.$safe_node.'\', \'desktop\')">Desktop</button>';
$html .= '<button class="gp-btn gp-btn-gray" onclick="gpOpenMeshSession(\''.$safe_node.'\', \'console\')">Terminal</button>';
$html .= '<button class="gp-btn gp-btn-gray" onclick="gpOpenMeshSession(\''.$safe_node.'\', \'files\')">Files</button>';
$html .= '<button class="gp-btn gp-btn-gray" onclick="gpDiagnoseMeshSession(\''.$safe_node.'\')">Diagnose</button>';
}
$html .= '</td></tr>';
}
return $html . '</table>';
}
public function ajax_dispatch_action() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error();
$host = rtrim((string)get_option('gp_mesh_host', ''), '/');
$key = (string)get_option('gp_mesh_key', '');
$mode = get_option('gp_mesh_mode', 'bridge');
$payload = [
'node_id' => sanitize_text_field($_POST['node_id']),
'action' => sanitize_text_field($_POST['action_type']),
'message' => sanitize_text_field($_POST['message'] ?? '')
];
if ($mode === 'legacy') {
$payload['key'] = $key;
$response = wp_remote_post($host . '/api/gracepress/action', [
'body' => wp_json_encode($payload),
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 10
]);
} else {
$headers = ['Content-Type' => 'application/json'];
if ($key !== '') $headers['Authorization'] = 'Bearer ' . $key;
$response = wp_remote_post($host . '/api/action', [
'body' => wp_json_encode($payload),
'headers' => $headers,
'timeout' => 10
]);
}
if (is_wp_error($response)) {
wp_send_json_error(['msg' => 'Failed to reach API']);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code >= 400 || (is_array($body) && empty($body['success']))) {
wp_send_json_error(['msg' => $body['error'] ?? $body['msg'] ?? 'Command was rejected by the bridge.']);
}
wp_send_json_success();
}
public function ajax_bridge_probe() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$probe = sanitize_key($_POST['probe'] ?? 'status');
$paths = [
'health' => '/api/health',
'version' => '/api/version',
'status' => '/api/status',
'selftest' => '/api/self-test',
'groups' => '/api/groups?refresh=1',
'devices' => '/api/devices?refresh=1',
'users' => '/api/users?refresh=1',
'events' => '/api/events?refresh=1&limit=50',
'serverinfo' => '/api/serverinfo',
'routes' => '/api/routes',
];
if (!isset($paths[$probe])) wp_send_json_error(['msg' => 'Unknown bridge probe.']);
$response = $this->bridge_request($paths[$probe], ['timeout' => $probe === 'selftest' ? 35 : 20]);
$data = $this->bridge_json_response($response);
if (empty($data['success'])) {
if (function_exists('gp_log')) gp_log('MeshPress bridge probe failed.', 'GP-MESH', ['severity' => 'ERROR', 'probe' => $probe, 'error' => $data['error'] ?? 'Unknown']);
wp_send_json_error(['msg' => $data['error'] ?? 'Bridge probe failed.', 'data' => $data]);
}
if (function_exists('gp_log')) gp_log('MeshPress bridge probe completed.', 'GP-MESH', ['severity' => 'INFO', 'probe' => $probe]);
wp_send_json_success($data);
}
public function ajax_update_bridge() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$data = $this->bridge_json_response($this->bridge_post('/api/update-now', [], ['timeout' => 12]));
if (empty($data['success'])) {
if (function_exists('gp_log')) gp_log('MeshPress bridge update trigger failed.', 'GP-MESH', ['severity' => 'ERROR', 'error' => $data['error'] ?? 'Unknown']);
wp_send_json_error(['msg' => $data['error'] ?? 'Bridge update trigger failed.', 'data' => $data]);
}
if (function_exists('gp_log')) gp_log('MeshPress bridge update manually triggered.', 'GP-MESH', ['severity' => 'INFO']);
wp_send_json_success($data);
}
public function ajax_create_group() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$name = sanitize_text_field($_POST['group_name'] ?? '');
$description = sanitize_text_field($_POST['group_description'] ?? '');
if ($name === '') wp_send_json_error(['msg' => 'Group name is required.']);
$response = $this->bridge_post('/api/groups/create', ['name' => $name, 'description' => $description]);
$data = $this->bridge_json_response($response);
if (empty($data['success'])) {
if (function_exists('gp_log')) gp_log('MeshPress group creation failed.', 'GP-MESH', ['severity' => 'ERROR', 'error' => $data['error'] ?? 'Unknown']);
wp_send_json_error(['msg' => $data['error'] ?? 'Could not create MeshCentral group.', 'data' => $data]);
}
$this->cache_clear();
if (function_exists('gp_log')) gp_log('MeshPress created MeshCentral group.', 'GP-MESH', ['severity' => 'SUCCESS', 'group' => $data['group']['id'] ?? '']);
wp_send_json_success($data);
}
public function ajax_agent_links() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? '');
if ($group_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral group first.']);
$response = $this->bridge_request('/api/agent-links/' . rawurlencode($group_id));
$data = $this->bridge_json_response($response);
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not generate agent links.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_device_links() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? '');
if ($node_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral device first.']);
$links = [];
$session = $this->bridge_json_response($this->bridge_request('/api/session-links/' . rawurlencode($node_id)));
if (!empty($session['success']) && is_array($session['links'] ?? null)) {
$links = $session['links'];
} else {
foreach (['desktop', 'console', 'files'] as $mode) {
$response = $this->bridge_request('/api/link/' . $mode . '/' . rawurlencode($node_id) . '?link_mode=all');
$data = $this->bridge_json_response($response);
if (!empty($data['success'])) $links[$mode] = $data['links'] ?? $data;
}
}
if (!$links) wp_send_json_error(['msg' => 'Could not generate device links.']);
wp_send_json_success(['links' => $links]);
}
public function ajax_device_info() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? '');
if ($node_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral device first.']);
$response = $this->bridge_request('/api/device/' . rawurlencode($node_id), ['timeout' => 25]);
$data = $this->bridge_json_response($response);
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not read device info.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_session_diagnostics() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? '');
if ($node_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral device first.']);
$response = $this->bridge_request('/api/session-diagnostics/' . rawurlencode($node_id), ['timeout' => 30]);
$data = $this->bridge_json_response($response);
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not build session diagnostics.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_resolve_group() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? '');
if ($group_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral group first.']);
$response = $this->bridge_request('/api/groups/resolve/' . rawurlencode($group_id), ['timeout' => 25]);
$data = $this->bridge_json_response($response);
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Could not resolve group.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_edit_group() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? '');
$name = sanitize_text_field($_POST['group_name'] ?? '');
$description = sanitize_text_field($_POST['group_description'] ?? '');
if ($group_id === '') wp_send_json_error(['msg' => 'Choose a MeshCentral group first.']);
if ($name === '' && $description === '') wp_send_json_error(['msg' => 'Enter a new group name or description.']);
$data = $this->bridge_json_response($this->bridge_post('/api/groups/edit', ['id' => $group_id, 'name' => $name, 'description' => $description]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Group edit failed.', 'data' => $data]);
$this->cache_clear();
wp_send_json_success($data);
}
public function ajax_rename_device() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? '');
$name = sanitize_text_field($_POST['device_name'] ?? '');
if ($node_id === '' || $name === '') wp_send_json_error(['msg' => 'Choose a device and enter a new name.']);
$data = $this->bridge_json_response($this->bridge_post('/api/device/rename', ['node_id' => $node_id, 'name' => $name]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Device rename failed.', 'data' => $data]);
$this->cache_clear();
wp_send_json_success($data);
}
public function ajax_move_device() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? '');
$group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? '');
if ($node_id === '' || $group_id === '') wp_send_json_error(['msg' => 'Choose a device and destination group.']);
$data = $this->bridge_json_response($this->bridge_post('/api/device/move', ['node_id' => $node_id, 'group_id' => $group_id]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Device move failed.', 'data' => $data]);
$this->cache_clear();
wp_send_json_success($data);
}
public function ajax_uninstall_agent() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$node_id = $this->sanitize_mesh_id($_POST['node_id'] ?? '');
if ($node_id === '') wp_send_json_error(['msg' => 'Choose a device first.']);
$data = $this->bridge_json_response($this->bridge_post('/api/device/uninstall', ['node_id' => $node_id], ['timeout' => 35]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Agent uninstall failed.', 'data' => $data]);
$this->cache_clear();
wp_send_json_success($data);
}
public function ajax_maintenance_probe() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$data = $this->bridge_json_response($this->bridge_request('/api/meshcentral/maintenance', ['timeout' => 35]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral maintenance probe failed.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_toggle_maintenance() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$enabled = !empty($_POST['enabled']) && $_POST['enabled'] !== '0' && $_POST['enabled'] !== 'false';
$data = $this->bridge_json_response($this->bridge_post('/api/maintenance/toggle', ['enabled' => $enabled], ['timeout' => 20]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Maintenance toggle failed.', 'data' => $data]);
if (function_exists('gp_log')) {
gp_log($enabled ? 'MeshPress bridge maintenance mode enabled.' : 'MeshPress bridge maintenance mode disabled.', 'GP-MESH', ['severity' => 'INFO']);
}
wp_send_json_success($data);
}
public function ajax_toggle_meshcentral_maintenance() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$enabled = !empty($_POST['enabled']) && $_POST['enabled'] !== '0' && $_POST['enabled'] !== 'false';
$restart = empty($_POST['no_restart']);
$data = $this->bridge_json_response($this->bridge_post('/api/meshcentral/maintenance/toggle', [
'enabled' => $enabled,
'restart' => $restart,
], ['timeout' => 40]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral maintenance toggle failed.', 'data' => $data]);
if (function_exists('gp_log')) {
gp_log($enabled ? 'MeshCentral maintenance mode enabled from MeshPress.' : 'MeshCentral maintenance mode disabled from MeshPress.', 'GP-MESH', ['severity' => 'INFO']);
}
wp_send_json_success($data);
}
public function ajax_meshcentral_config_audit() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$data = $this->bridge_json_response($this->bridge_request('/api/meshcentral/config-audit', ['timeout' => 35]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral config audit failed.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_meshcentral_backup() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$stop_service = !empty($_POST['stop_service']) && $_POST['stop_service'] !== '0' && $_POST['stop_service'] !== 'false';
$data = $this->bridge_json_response($this->bridge_post('/api/meshcentral/backup', ['stop_service' => $stop_service], ['timeout' => 90]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral backup failed.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_meshcentral_update() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$data = $this->bridge_json_response($this->bridge_post('/api/meshcentral/update', [], ['timeout' => 20]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'MeshCentral update failed.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_clear_cache() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$this->cache_clear();
$this->bridge_post('/api/cache/clear', []);
if (function_exists('gp_log')) gp_log('MeshPress fleet cache cleared.', 'GP-MESH', ['severity' => 'INFO']);
wp_send_json_success(['message' => 'MeshPress cache cleared.']);
}
public function ajax_push_now() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$data = $this->bridge_json_response($this->bridge_post('/api/push-now', []));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Bridge push failed.', 'data' => $data]);
wp_send_json_success($data);
}
public function ajax_health_check() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$checks = [];
$status = $this->bridge_json_response($this->bridge_request('/api/status', ['timeout' => 20]));
$selftest = $this->bridge_json_response($this->bridge_request('/api/self-test', ['timeout' => 35]));
$fleet = $this->bridge_json_response($this->bridge_request('/api/fleet?refresh=1', ['timeout' => 35]));
$last_event = get_transient('gp_mesh_last_bridge_event');
$cached = get_transient('gp_mesh_fleet_cache');
$checks[] = [
'key' => 'bridge',
'label' => 'WordPress to Bridge',
'ok' => !empty($status['success']),
'detail' => !empty($status['success']) ? 'Bridge reachable on ' . ($status['bind'] ?? '?') . ':' . ($status['port'] ?? '?') : ($status['error'] ?? 'Bridge unavailable.')
];
$checks[] = [
'key' => 'meshctrl',
'label' => 'Bridge to MeshCentral',
'ok' => !empty($selftest['success']),
'detail' => !empty($selftest['success']) ? 'MeshCtrl, auth, groups, and devices passed self-test.' : ($selftest['error'] ?? 'Self-test failed.')
];
$checks[] = [
'key' => 'public_url',
'label' => 'Public MeshCentral URL',
'ok' => !empty($status['public_mesh_url_configured']),
'detail' => $status['public_mesh_url'] ?? 'PUBLIC_MESH_URL is not configured.'
];
$checks[] = [
'key' => 'fleet',
'label' => 'Fleet Freshness',
'ok' => !empty($fleet['success']),
'detail' => !empty($fleet['success']) ? count((array)($fleet['devices'] ?? [])). ' devices, ' . count((array)($fleet['groups'] ?? [])) . ' groups.' : ($fleet['error'] ?? 'Fleet refresh failed.')
];
$checks[] = [
'key' => 'push',
'label' => 'Bridge Push',
'ok' => !empty($status['wp_push_configured']),
'detail' => !empty($status['wp_push_configured']) ? 'Push configured for ' . ($status['wp_domain'] ?? 'WordPress') : 'WP_DOMAIN/WP_API_KEY are not configured on the bridge.'
];
$checks[] = [
'key' => 'omnilog',
'label' => 'OmniLog Events',
'ok' => is_array($last_event),
'detail' => is_array($last_event) ? 'Last event: ' . ($last_event['event'] ?? 'unknown') . ' at ' . ($last_event['received_at'] ?? 'unknown') : 'No bridge event has been received yet.'
];
if (!empty($fleet['success'])) {
set_transient('gp_mesh_fleet_cache', [
'groups' => (array)($fleet['groups'] ?? []),
'devices' => (array)($fleet['devices'] ?? []),
'synced_at' => current_time('mysql'),
'source' => 'health_check',
'bridge_version' => sanitize_text_field($fleet['bridge_version'] ?? '')
], DAY_IN_SECONDS);
set_transient('gp_mesh_nodes_cache', (array)($fleet['devices'] ?? []), DAY_IN_SECONDS);
}
if (function_exists('gp_log')) gp_log('MeshPress connection health check completed.', 'GP-MESH', ['severity' => 'INFO']);
wp_send_json_success(['checks' => $checks, 'status' => $status, 'selftest' => $selftest, 'fleet' => $fleet, 'cached' => $cached]);
}
public function ajax_wait_enrollment() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error(['msg' => 'Not allowed.']);
$group_id = $this->sanitize_mesh_id($_POST['group_id'] ?? '');
if ($group_id === '') wp_send_json_error(['msg' => 'Choose a group first.']);
$data = $this->bridge_json_response($this->bridge_request('/api/fleet?refresh=1', ['timeout' => 35]));
if (empty($data['success'])) wp_send_json_error(['msg' => $data['error'] ?? 'Fleet refresh failed.', 'data' => $data]);
$devices = (array)($data['devices'] ?? []);
$matches = [];
$wanted_short = basename(str_replace('\\', '/', $group_id));
foreach ($devices as $device) {
$device_group = (string)($device['groupId'] ?? $device['meshid'] ?? '');
$device_short = basename(str_replace('\\', '/', $device_group));
if ($device_group === $group_id || $device_short === $wanted_short) {
$matches[] = $device;
}
}
set_transient('gp_mesh_fleet_cache', [
'groups' => (array)($data['groups'] ?? []),
'devices' => $devices,
'synced_at' => current_time('mysql'),
'source' => 'enrollment_wait',
'bridge_version' => sanitize_text_field($data['bridge_version'] ?? '')
], DAY_IN_SECONDS);
set_transient('gp_mesh_nodes_cache', $devices, DAY_IN_SECONDS);
if (function_exists('gp_log')) gp_log('MeshPress enrollment check completed.', 'GP-MESH', ['severity' => $matches ? 'SUCCESS' : 'INFO', 'matches' => count($matches)]);
wp_send_json_success(['matches' => $matches, 'count' => count($matches), 'devices' => $devices, 'groups' => (array)($data['groups'] ?? [])]);
}
public function ajax_get_fleet() {
check_ajax_referer('gp_mesh_nonce', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error();
$host = rtrim((string)get_option('gp_mesh_host', ''), '/');
$key = (string)get_option('gp_mesh_key', '');
$mode = get_option('gp_mesh_mode', 'bridge');
$public = rtrim((string)get_option('gp_mesh_public_url', ''), '/');
if (empty($host) || empty($key)) {
wp_send_json_success(['html' => '<div style="padding:20px; color:#ef4444; font-weight:bold; text-align:center;">Bridge URL or token missing. Go to Connection Setup.</div>']);
}
if ($mode === 'legacy') {
$response = wp_remote_get($host . '/api/gracepress/fleet?key=' . urlencode($key), ['timeout' => 15]);
} else {
$response = $this->bridge_request('/api/fleet?refresh=1');
}
if (is_wp_error($response)) {
if (function_exists('gp_log')) gp_log("MeshPress Bridge unreachable: " . $response->get_error_message(), 'GP-MESH', ['severity' => 'ERROR']);
$cached = get_transient('gp_mesh_fleet_cache');
if (is_array($cached) && !empty($cached['devices'])) {
wp_send_json_success(['html' => '<div style="padding:12px;margin-bottom:12px;background:#ecfdf5;border:1px solid #a7f3d0;border-radius:6px;color:#047857;font-weight:bold;">Showing pushed fleet snapshot from ' . esc_html($cached['synced_at'] ?? 'cache') . '.</div>' . $this->render_fleet_table($cached['devices'], $cached['groups'] ?? [])]);
}
wp_send_json_success(['html' => '<div style="padding:20px; color:#ef4444; font-weight:bold; text-align:center;">Failed to connect to MeshPress Bridge. Check URL, token, and firewall/Tailscale reachability.</div>']);
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (!$data || empty($data['success'])) {
if (function_exists('gp_log')) gp_log("MeshPress Bridge rejected request or returned invalid JSON.", 'GP-MESH', ['severity' => 'ERROR', 'response' => substr($body, 0, 500)]);
wp_send_json_success(['html' => '<div style="padding:20px; color:#ef4444; font-weight:bold; text-align:center;">Bridge rejected the request: ' . esc_html($data['error'] ?? 'Invalid response') . '</div>']);
}
$nodes = $data['devices'] ?? $data['nodes'] ?? [];
$meshes = $data['groups'] ?? $data['meshes'] ?? [];
if (empty($nodes)) {
wp_send_json_success(['html' => '<div style="padding:20px; color:#64748b; text-align:center;">No devices found in MeshCentral database.</div>']);
}
set_transient('gp_mesh_fleet_cache', ['groups' => $meshes, 'devices' => $nodes, 'synced_at' => current_time('mysql')], DAY_IN_SECONDS);
set_transient('gp_mesh_nodes_cache', $nodes, DAY_IN_SECONDS);
if (function_exists('gp_log')) gp_log("MeshCentral Fleet Synced Successfully via MeshPress Bridge.", 'GP-MESH', ['severity' => 'SUCCESS', 'devices' => count($nodes), 'groups' => count($meshes)]);
wp_send_json_success(['html' => $this->render_fleet_table($nodes, $meshes)]);
}
// =====================================================================
// 4. ADMIN DASHBOARD UI
// =====================================================================
public function register_admin_page() {
$slug = 'mesh-press';
$title = 'MeshPress Sync';
global $admin_page_hooks;
if (isset($admin_page_hooks['service-desk'])) {
add_submenu_page('service-desk', 'MeshPress', $title, 'manage_options', $slug, [$this, 'admin_ui']);
} elseif (class_exists('GracePress_Central_Hub')) {
add_submenu_page('gracepress-central', 'MeshPress', $title, 'manage_options', $slug, [$this, 'admin_ui']);
} else {
add_menu_page('MeshPress', $title, 'manage_options', $slug, [$this, 'admin_ui'], 'dashicons-admin-site-alt3', 6);
}
}
public function admin_ui() {
if (!current_user_can('manage_options')) return;
$host = get_option('gp_mesh_host', '');
$key = get_option('gp_mesh_key', '');
$public_url = get_option('gp_mesh_public_url', 'https://mesh.christit.com');
$mode = get_option('gp_mesh_mode', 'bridge');
$link_mode = get_option('gp_mesh_link_mode', 'gotonode');
$advanced_mode = get_option('gp_mesh_advanced_mode', 'no') === 'yes';
$last_event = get_transient('gp_mesh_last_bridge_event');
$cached_fleet = get_transient('gp_mesh_fleet_cache');
$cached_groups = is_array($cached_fleet['groups'] ?? null) ? $cached_fleet['groups'] : [];
$cached_devices = is_array($cached_fleet['devices'] ?? null) ? $cached_fleet['devices'] : [];
?>
<style>
.gp-wrap { max-width: 1400px; margin: 20px 20px 20px 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #334155; }
.gp-wrap * { box-sizing: border-box; }
.gp-header { background: linear-gradient(135deg, rgba(15,23,42,0.95), rgba(6,182,212,0.85)), url('https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=2000&auto=format&fit=crop') center/cover; color: #fff; padding: 40px; border-radius: 12px; margin-bottom: 25px; border-left: 8px solid #06b6d4; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
.gp-header h1 { color: #fff; margin: 0 0 10px 0; font-size: 32px; font-weight: 800; letter-spacing: -0.5px; }
.gp-tabs { display:flex; gap:10px; margin-bottom:20px; border-bottom:2px solid #e2e8f0; padding-bottom:10px;}
.gp-tab { padding:10px 20px; font-weight:bold; color:#64748b; cursor:pointer; border-radius:6px; transition:0.2s; }
.gp-tab.active { background:#06b6d4; color:#fff; }
.gp-tab-content { display:none; }
.gp-tab-content.active { display:block; animation:fadeIn 0.3s ease; }
@keyframes fadeIn { from{opacity:0;} to{opacity:1;} }
.gp-card { background:#fff; border-radius:12px; border:1px solid #e2e8f0; padding:25px; margin-bottom:20px; box-shadow:0 4px 6px rgba(0,0,0,0.02); }
.gp-card h3 { margin-top:0; border-bottom:1px solid #e2e8f0; padding-bottom:10px; }
.gp-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:16px; }
.gp-field { display:flex; flex-direction:column; gap:6px; margin-bottom:12px; }
.gp-field span { color:#475569; font-size:12px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; }
.gp-input { width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:6px; background:#fff; color:#0f172a; }
.gp-output { background:#0f172a; color:#dbeafe; border:1px solid #334155; border-radius:8px; min-height:150px; max-height:420px; overflow:auto; padding:14px; font:12px/1.5 Consolas, Monaco, monospace; white-space:pre-wrap; }
.gp-link-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:8px; border:1px solid #e2e8f0; border-radius:8px; background:#f8fafc; margin:7px 0; }
.gp-link-row strong { min-width:150px; color:#334155; }
.gp-link-row input { flex:1; min-width:260px; padding:7px; border:1px solid #cbd5e1; border-radius:5px; }
.gp-agent-panel { display:grid; gap:14px; }
.gp-agent-note { border:1px solid #bfdbfe; background:#eff6ff; color:#1e3a8a; border-radius:10px; padding:12px; line-height:1.45; }
.gp-agent-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; }
.gp-agent-card { border:1px solid #dbeafe; border-radius:10px; background:linear-gradient(180deg,#ffffff,#f8fbff); padding:12px; display:flex; flex-direction:column; gap:8px; min-height:118px; }
.gp-agent-card strong { color:#0f172a; font-size:15px; }
.gp-agent-card small { color:#64748b; line-height:1.35; }
.gp-agent-actions { margin-top:auto; display:flex; gap:8px; flex-wrap:wrap; }
.gp-agent-primary { border:1px solid #93c5fd; border-radius:12px; background:linear-gradient(135deg,#eff6ff,#ffffff); padding:14px; }
.gp-agent-primary h4 { margin:0 0 8px; color:#0f172a; font-size:16px; }
.gp-agent-picker { display:grid; grid-template-columns:minmax(180px,280px) 1fr auto; gap:10px; align-items:end; margin:10px 0; }
@media (max-width: 900px) { .gp-agent-picker { grid-template-columns:1fr; } }
.gp-command-box { display:flex; gap:8px; align-items:stretch; border:1px solid #bfdbfe; border-radius:10px; background:#0f172a; padding:10px; min-width:0; }
.gp-command-box code { flex:1; color:#dbeafe; font:12px/1.45 Consolas, Monaco, monospace; white-space:normal; word-break:break-all; }
.gp-command-box .gp-btn { align-self:center; flex:0 0 auto; }
.gp-agent-summary { display:flex; flex-wrap:wrap; gap:8px; margin:8px 0 12px; }
.gp-agent-pill { display:inline-flex; align-items:center; gap:5px; border:1px solid #cbd5e1; border-radius:999px; background:#f8fafc; color:#334155; padding:4px 9px; font-size:12px; font-weight:800; }
.gp-advanced { border:1px dashed #cbd5e1; border-radius:10px; padding:10px; background:#f8fafc; }
.gp-advanced summary { cursor:pointer; font-weight:800; color:#475569; }
.gp-status-panel { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:12px; margin:14px 0; }
.gp-status-tile { background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:14px; }
.gp-status-tile span { display:block; color:#64748b; font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px; }
.gp-status-tile strong { display:block; color:#0f172a; font-size:18px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.gp-status-tile.ok { border-color:#bbf7d0; background:#f0fdf4; }
.gp-status-tile.warn { border-color:#fed7aa; background:#fff7ed; }
.gp-checklist { display:grid; gap:10px; margin:12px 0; }
.gp-check-item { display:grid; grid-template-columns:26px 1fr; gap:10px; align-items:start; padding:10px; border:1px solid #e2e8f0; border-radius:10px; background:#f8fafc; }
.gp-check-mark { width:22px; height:22px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; background:#e2e8f0; color:#475569; font-weight:900; }
.gp-check-item.ok .gp-check-mark { background:#10b981; color:#fff; }
.gp-check-item strong { display:block; color:#0f172a; }
.gp-check-item small { display:block; color:#64748b; margin-top:2px; line-height:1.35; }
.gp-advanced-only { <?php echo $advanced_mode ? '' : 'display:none !important;'; ?> }
.gp-danger-zone { border-left:4px solid #ef4444; }
.gp-fleet-toolbar { display:grid; grid-template-columns:1.2fr 1.5fr .8fr 1fr; gap:10px; align-items:center; margin-bottom:16px; padding:12px; border:1px solid #dbeafe; border-radius:10px; background:#eff6ff; }
.gp-fleet-group { border:1px solid #dbeafe; border-radius:12px; overflow:hidden; margin-bottom:14px; background:#f8fbff; }
.gp-fleet-group.collapsed .gp-fleet-device-grid { display:none; }
.gp-fleet-group-head { display:flex; justify-content:space-between; gap:12px; align-items:center; padding:12px 14px; background:linear-gradient(90deg,#dbeafe,#f8fafc); border-bottom:1px solid #dbeafe; }
.gp-fleet-group-head strong { display:block; font-size:15px; color:#0f172a; }
.gp-fleet-group-head span { display:block; color:#64748b; font-size:12px; margin-top:2px; }
.gp-fleet-device-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(270px,1fr)); gap:10px; padding:12px; }
.gp-device-card { border:1px solid #d7e3f4; border-radius:10px; padding:12px; background:#fff; display:flex; flex-direction:column; gap:10px; }
.gp-device-card[style*="display: none"] { display:none !important; }
.gp-device-main { display:flex; gap:10px; align-items:center; min-width:0; }
.gp-device-main strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#0f172a; }
.gp-device-main small { display:block; color:#64748b; margin-top:2px; }
.gp-device-actions { display:flex; gap:6px; flex-wrap:wrap; }
.gp-table { width:100%; border-collapse:collapse; font-size:14px; }
.gp-table th { background:#f8fafc; padding:12px; text-align:left; border-bottom:2px solid #e2e8f0; color:#64748b; font-size:12px; text-transform:uppercase; }
.gp-table td { padding:12px; border-bottom:1px solid #e2e8f0; }
.gp-status-dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:5px; }
.gp-badge { padding:4px 8px; border-radius:4px; font-size:11px; font-weight:bold; text-transform:uppercase; border:1px solid transparent; }
.gp-badge-blue { background:#eff6ff; color:#2563eb; border-color:#bfdbfe; }
.gp-btn { border:none; padding:6px 12px; border-radius:4px; font-weight:bold; cursor:pointer; transition:0.2s; font-size:12px; }
.gp-btn-green { background:#10b981; color:#fff; } .gp-btn-green:hover { background:#059669; }
.gp-btn-blue { background:#3b82f6; color:#fff; } .gp-btn-blue:hover { background:#2563eb; }
.gp-btn-red { background:#ef4444; color:#fff; } .gp-btn-red:hover { background:#dc2626; }
.gp-btn-gray { background:#f1f5f9; color:#475569; border:1px solid #cbd5e1; } .gp-btn-gray:hover { background:#e2e8f0; }
.gp-select { padding:6px; border:1px solid #cbd5e1; border-radius:4px; font-size:12px; color:#0f172a; outline:none; }
.setup-step { background:#f8fafc; border:1px solid #e2e8f0; padding:15px; border-radius:8px; margin-bottom:15px; display:flex; gap:15px; align-items:start;}
.step-num { background:#06b6d4; color:#fff; width:30px; height:30px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:bold; flex-shrink:0; }
/* Sleek Toast Notifications */
.gp-toast { position:fixed; bottom:30px; left:30px; padding:15px 25px; border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.2); z-index:99999; display:none; font-weight:bold; color:#fff; animation: slideInLeft 0.5s cubic-bezier(0.25, 1, 0.5, 1); }
.gp-toast.success { background:#10b981; }
.gp-toast.error { background:#ef4444; }
.gp-toast.info { background:#3b82f6; }
@keyframes slideInLeft { from { transform: translateX(-150%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
<div id="gp-toast" class="gp-toast"></div>
<div class="gp-wrap">
<div class="gp-header">
<h1>GracePress MeshPress Sync</h1>
<p style="margin:0; opacity:0.9; font-size:16px;">Enterprise RMM bridge. Communicates with the GracePress MeshPress Bridge for MeshCentral fleet syncing.</p>
</div>
<div class="gp-tabs">
<div class="gp-tab active" onclick="gpSwitchTab('overview', this)">Fleet Overview & Actions</div>
<div class="gp-tab gp-advanced-only" onclick="gpSwitchTab('ops', this)">MeshCentral Ops</div>
<div class="gp-tab gp-advanced-only" onclick="gpSwitchTab('troubleshooting', this)">Troubleshooting</div>
<div class="gp-tab" onclick="gpSwitchTab('setup', this)">Connection Setup</div>
<div class="gp-tab" onclick="gpSwitchTab('provision', this)">Deploy Agents</div>
</div>
<div id="tab-overview" class="gp-tab-content active">
<div class="gp-card">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:12px; flex-wrap:wrap;">
<div>
<h3 style="border:none; margin:0;">Connection Health</h3>
<p style="margin:6px 0 0; color:#64748b;">Checks the bridge, MeshCentral, public session URL, fleet cache, push settings, and OmniLog event flow.</p>
</div>
<button class="gp-btn gp-btn-blue" onclick="gpRunHealthCheck()">Run Health Check</button>
</div>
<div id="gp-health-panel" class="gp-status-panel">
<div class="gp-status-tile"><span>Status</span><strong>Not checked</strong></div>
<?php if(is_array($last_event)): ?>
<div class="gp-status-tile ok"><span>Last OmniLog Event</span><strong title="<?php echo esc_attr($last_event['event'] ?? ''); ?>"><?php echo esc_html($last_event['event'] ?? 'Bridge event'); ?></strong></div>
<?php endif; ?>
</div>
<details class="gp-advanced" style="margin-top:10px;">
<summary>Raw health output</summary>
<div id="gp-health-output" class="gp-output" style="margin-top:10px; min-height:80px;">Run a health check to collect diagnostics.</div>
</details>
</div>
<div class="gp-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h3 style="border:none; margin:0;">Managed Devices</h3>
<button class="gp-btn gp-btn-blue" onclick="gpFetchFleet()">Sync API Data</button>
</div>
<div id="gp-fleet-container">
<div style="text-align:center; padding:40px; color:#64748b;">
<?php if(empty($host) || empty($key)) echo "API Key is missing. Go to the Connection Setup tab."; else echo "Click 'Sync API Data' to fetch computers..."; ?>
</div>
</div>
</div>
</div>
<div id="tab-ops" class="gp-tab-content gp-advanced-only">
<div class="gp-grid">
<div class="gp-card">
<h3>MeshCentral Runtime</h3>
<p style="color:#64748b;">Reads MeshCentral package/runtime details through the bridge. Bridge maintenance unlocks guarded bridge actions; MeshCentral maintenance writes <code>settings.maintenanceMode</code> into MeshCentral config and restarts the service.</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
<button class="gp-btn gp-btn-blue" onclick="gpMaintenanceProbe()">Read MeshCentral Status</button>
<button class="gp-btn gp-btn-green" onclick="gpToggleMaintenance(true)">Enable Bridge Maintenance</button>
<button class="gp-btn gp-btn-red" onclick="gpToggleMaintenance(false)">Disable Bridge Maintenance</button>
<button class="gp-btn gp-btn-green" onclick="gpToggleMeshCentralMaintenance(true)">Enable MeshCentral Maintenance</button>
<button class="gp-btn gp-btn-red" onclick="gpToggleMeshCentralMaintenance(false)">Disable MeshCentral Maintenance</button>
<button class="gp-btn gp-btn-green" onclick="gpMeshCentralUpdate()">Trigger MeshCentral Update</button>
</div>
<div id="gp-maintenance-output" class="gp-output">No MeshCentral maintenance probe has been run yet.</div>
</div>
<div class="gp-card">
<h3>MeshCentral Backups & Config Audit</h3>
<p style="color:#64748b;">Creates a full snapshot of MeshCentral data/config and, when MySQL or MariaDB is configured, includes a database dump. The audit output is redacted for copy/paste diagnostics.</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
<button class="gp-btn gp-btn-blue" onclick="gpMeshCentralBackup(false)">Create Live Full Backup</button>
<button class="gp-btn gp-btn-red" onclick="gpMeshCentralBackup(true)">Create Offline Full Backup</button>
<button class="gp-btn gp-btn-green" onclick="gpConfigAudit()">Run Config Audit</button>
</div>
<div id="gp-backup-output" class="gp-output" style="margin-top:12px;">Backup tools are intentionally guarded. Run status first to confirm bridge maintenance settings.</div>
</div>
</div>
<div class="gp-card">
<h3>Agent Lifecycle</h3>
<p style="color:#64748b;">Manual uninstall diagnostics for a selected MeshCentral agent. Keep this as an operator-only test until we prove the exact MeshCtrl command on your MeshCentral version.</p>
<div style="display:grid; grid-template-columns:1fr auto; gap:10px; align-items:end;">
<label class="gp-field"><span>Device</span>
<select class="gp-input" id="gp-agent-lifecycle-node">
<option value="">Choose a cached MeshCentral device</option>
<?php foreach($cached_devices as $device):
$nid = $device['id'] ?? $device['_id'] ?? '';
if(!$nid) continue;
?>
<option value="<?php echo esc_attr($nid); ?>"><?php echo esc_html(($device['name'] ?? $nid) . ' - ' . ($device['groupName'] ?? '')); ?></option>
<?php endforeach; ?>
</select>
</label>
<button class="gp-btn gp-btn-red" onclick="gpUninstallAgent()">Request Agent Uninstall</button>
</div>
<div id="gp-agent-lifecycle-output" class="gp-output" style="margin-top:12px;">No lifecycle action has been run yet.</div>
</div>
</div>
<div id="tab-troubleshooting" class="gp-tab-content gp-advanced-only">
<div class="gp-card">
<h3>Bridge Runtime Status</h3>
<p style="color:#64748b;">Check the live bridge version, install path, updater availability, and trigger the catalog updater manually.</p>
<div id="gp-bridge-status" class="gp-status-panel">
<div class="gp-status-tile"><span>Status</span><strong>Not checked</strong></div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="gp-btn gp-btn-blue" onclick="gpCheckBridgeVersion()">Check Bridge Version</button>
<button class="gp-btn gp-btn-green" onclick="gpUpdateBridge()">Update Bridge Now</button>
</div>
<div id="gp-bridge-update-output" style="margin-top:12px;"></div>
</div>
<div class="gp-grid">
<div class="gp-card">
<h3>Bridge Probes</h3>
<p style="color:#64748b;">Run safe checks against the local bridge before wiring actions into Service Desk.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
<button class="gp-btn gp-btn-blue" onclick="gpBridgeProbe('health')">Health</button>
<button class="gp-btn gp-btn-blue" onclick="gpBridgeProbe('version')">Version</button>
<button class="gp-btn gp-btn-blue" onclick="gpBridgeProbe('status')">Status</button>
<button class="gp-btn gp-btn-blue" onclick="gpBridgeProbe('selftest')">Self-Test</button>
<button class="gp-btn gp-btn-blue" onclick="gpBridgeProbe('routes')">Routes</button>
<button class="gp-btn gp-btn-gray" onclick="gpBridgeProbe('groups')">Groups</button>
<button class="gp-btn gp-btn-gray" onclick="gpBridgeProbe('devices')">Devices</button>
<button class="gp-btn gp-btn-gray" onclick="gpBridgeProbe('users')">Users</button>
<button class="gp-btn gp-btn-gray" onclick="gpBridgeProbe('events')">Events</button>
<button class="gp-btn gp-btn-gray" onclick="gpBridgeProbe('serverinfo')">Server Info</button>
<button class="gp-btn gp-btn-green" onclick="gpPushNow()">Push Fleet</button>
<button class="gp-btn gp-btn-gray" onclick="gpClearMeshCache()">Clear Cache</button>
</div>
<div id="gp-diagnostics-output" class="gp-output">Run a probe to see raw bridge results. Bridge logs now use copy-friendly JSON lines prefixed with [meshpress-bridge]. Useful events: session_links, session_diagnostics, group_resolve, group_edit_attempt, group_edit_success, group_edit_failed.</div>
</div>
<div class="gp-card">
<h3>Create MeshCentral Group</h3>
<p style="color:#64748b;">This requires <code>MESHPRESS_ALLOW_COMMANDS=1</code> on the bridge.</p>
<label class="gp-field"><span>Group Name</span><input class="gp-input" id="gp-manual-group-name" placeholder="Client or test group name"></label>
<label class="gp-field"><span>Description</span><input class="gp-input" id="gp-manual-group-description" placeholder="Created from MeshPress diagnostics"></label>
<button class="gp-btn gp-btn-green" onclick="gpCreateManualGroup()">Create Group</button>
<div id="gp-group-output" style="margin-top:14px;"></div>
</div>
</div>
<div class="gp-grid">
<div class="gp-card">
<h3>Edit MeshCentral Group</h3>
<p style="color:#64748b;">Rename or update a group description, then clear/push fleet cache.</p>
<label class="gp-field"><span>Device Group</span>
<select class="gp-input" id="gp-edit-group-id">
<option value="">Choose a cached MeshCentral group</option>
<?php foreach($cached_groups as $group):
$gid = $group['id'] ?? $group['_id'] ?? '';
if(!$gid) continue;
?>
<option value="<?php echo esc_attr($gid); ?>"><?php echo esc_html($group['name'] ?? $gid); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="gp-field"><span>New Name</span><input class="gp-input" id="gp-edit-group-name" placeholder="Leave blank to keep current name"></label>
<label class="gp-field"><span>Description</span><input class="gp-input" id="gp-edit-group-description" placeholder="Leave blank to keep current description"></label>
<button class="gp-btn gp-btn-blue" onclick="gpEditMeshGroup()">Save Group Changes</button>
<button class="gp-btn gp-btn-gray" onclick="gpResolveMeshGroup()">Resolve Group ID</button>
<div id="gp-edit-group-output" style="margin-top:14px;"></div>
</div>
<div class="gp-card">
<h3>Agent Link Generator</h3>
<p style="color:#64748b;">Choose a MeshCentral device group, then copy a Linux install command or open a platform download. MeshPress fills the Mesh ID automatically; you should not have to paste it manually.</p>
<label class="gp-field"><span>Device Group</span>
<select class="gp-input" id="gp-agent-group">
<option value="">Choose a cached MeshCentral group</option>
<?php foreach($cached_groups as $group):
$gid = $group['id'] ?? $group['_id'] ?? '';
if(!$gid) continue;
?>
<option value="<?php echo esc_attr($gid); ?>"><?php echo esc_html($group['name'] ?? $gid); ?></option>
<?php endforeach; ?>
</select>
</label>
<button class="gp-btn gp-btn-blue" onclick="gpGenerateAgentLinks()">Generate Agent Links</button>
<div id="gp-agent-output" style="margin-top:14px;"></div>
</div>
<div class="gp-card">
<h3>Device Link Tester</h3>
<p style="color:#64748b;">Generates Desktop, Terminal, and Files URLs in every supported MeshCentral direct-link pattern. Start with Terminal > primary, then try alternates if your MeshCentral build prefers another parameter order.</p>
<label class="gp-field"><span>Device</span>
<select class="gp-input" id="gp-device-node">
<option value="">Choose a cached MeshCentral device</option>
<?php foreach($cached_devices as $device):
$nid = $device['id'] ?? $device['_id'] ?? '';
if(!$nid) continue;
?>
<option value="<?php echo esc_attr($nid); ?>"><?php echo esc_html(($device['name'] ?? $nid) . ' - ' . ($device['groupName'] ?? '')); ?></option>
<?php endforeach; ?>
</select>
</label>
<button class="gp-btn gp-btn-blue" onclick="gpGenerateDeviceLinks()">Generate Session Links</button>
<button class="gp-btn gp-btn-green" onclick="gpOpenSelectedConsole()">Open Terminal</button>
<button class="gp-btn gp-btn-gray" onclick="gpGetDeviceInfo()">Device Info</button>
<button class="gp-btn gp-btn-gray" onclick="gpDiagnoseSelectedDevice()">Diagnose Session</button>
<button class="gp-btn gp-btn-red" onclick="gpUninstallSelectedAgent()">Uninstall Agent</button>
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:12px;">
<input class="gp-input" id="gp-rename-device-name" placeholder="New device name">
<button class="gp-btn gp-btn-blue" onclick="gpRenameDevice()">Rename</button>
</div>
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:8px;">
<select class="gp-input" id="gp-move-device-group">
<option value="">Move to group...</option>
<?php foreach($cached_groups as $group):
$gid = $group['id'] ?? $group['_id'] ?? '';
if(!$gid) continue;
?>
<option value="<?php echo esc_attr($gid); ?>"><?php echo esc_html($group['name'] ?? $gid); ?></option>
<?php endforeach; ?>
</select>
<button class="gp-btn gp-btn-blue" onclick="gpMoveDevice()">Move</button>
</div>
<div id="gp-device-output" style="margin-top:14px;"></div>
</div>
</div>
</div>
<div id="tab-setup" class="gp-tab-content">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
<div class="gp-card">
<h3><span class="dashicons dashicons-shield"></span> New Server Onboarding</h3>
<div class="gp-checklist">
<div class="gp-check-item <?php echo $host ? 'ok' : ''; ?>"><span class="gp-check-mark"><?php echo $host ? 'OK' : '1'; ?></span><div><strong>Bridge URL</strong><small>Point WordPress at the bridge running beside the new MeshCentral server.</small></div></div>
<div class="gp-check-item <?php echo $key ? 'ok' : ''; ?>"><span class="gp-check-mark"><?php echo $key ? 'OK' : '2'; ?></span><div><strong>Bridge Token</strong><small>Use the token from <code>/etc/gracepress/meshpress-bridge.env</code>.</small></div></div>
<div class="gp-check-item <?php echo $public_url ? 'ok' : ''; ?>"><span class="gp-check-mark"><?php echo $public_url ? 'OK' : '3'; ?></span><div><strong>Public MeshCentral URL</strong><small>This URL is used for Desktop, Terminal, Files, and agent installer links.</small></div></div>
<div class="gp-check-item"><span class="gp-check-mark">4</span><div><strong>Bridge Self-Test</strong><small>After saving, run the health check to confirm MeshCtrl path, auth, groups, devices, push, and OmniLog.</small></div></div>
</div>
<label style="display:block; font-weight:bold; margin-bottom:5px;">Connection Mode</label>
<select id="gp-mesh-mode" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:6px; margin-bottom:15px;">
<option value="bridge" <?php selected($mode, 'bridge'); ?>>MeshPress Bridge</option>
<option value="legacy" <?php selected($mode, 'legacy'); ?>>Legacy Custom MeshCentral Plugin</option>
</select>
<label style="display:block; font-weight:bold; margin-bottom:5px;">Bridge URL</label>
<input type="text" id="gp-mesh-url" value="<?php echo esc_attr($host); ?>" placeholder="http://vic:3099" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:6px; margin-bottom:15px;">
<label style="display:block; font-weight:bold; margin-bottom:5px;">Bridge Token</label>
<input type="password" id="gp-mesh-key" value="<?php echo esc_attr($key); ?>" placeholder="Bearer token from /etc/gracepress/meshpress-bridge.env" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:6px; margin-bottom:15px;">
<label style="display:block; font-weight:bold; margin-bottom:5px;">Public MeshCentral URL</label>
<input type="text" id="gp-mesh-public-url" value="<?php echo esc_attr($public_url); ?>" placeholder="https://mesh.christit.com" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:6px; margin-bottom:15px;">
<label style="display:block; font-weight:bold; margin-bottom:5px;">Direct Link Format</label>
<select id="gp-mesh-link-mode" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:6px; margin-bottom:15px;">
<option value="gotonode" <?php selected($link_mode, 'gotonode'); ?>>gotonode + viewmode</option>
<option value="node" <?php selected($link_mode, 'node'); ?>>node + viewmode</option>
</select>
<label style="display:flex; gap:8px; align-items:center; font-weight:bold; margin-bottom:15px;">
<input type="checkbox" id="gp-mesh-advanced-mode" value="1" <?php checked($advanced_mode); ?>>
Show advanced diagnostics and destructive controls
</label>
<button class="gp-btn gp-btn-blue" style="width:100%; padding:12px; font-size:14px;" onclick="gpSaveAuth()">Save Configuration</button>
</div>
<div class="gp-card" style="border-left:4px solid #f59e0b;">
<h3><span class="dashicons dashicons-warning"></span> Required Configuration</h3>
<p>This plugin now talks to the catalog-managed <b>MeshPress Bridge</b> service. The bridge runs beside MeshCentral and uses MeshCtrl to read groups and devices.</p>
<div class="setup-step">
<div class="step-num">1</div>
<div><strong>Install the Bridge on the New MeshCentral Server</strong><br><span style="color:#64748b; font-size:13px;">Use <code>tools/meshpress-bridge/install.sh</code>, then configure <code>/etc/gracepress/meshpress-bridge.env</code>.</span></div>
</div>
<div class="setup-step">
<div class="step-num">2</div>
<div><strong>Expose the Bridge Safely</strong><br><span style="color:#64748b; font-size:13px;">Prefer localhost plus private proxy/VPN, or bind to a private address with strict firewall and bridge token controls.</span></div>
</div>
<div class="setup-step">
<div class="step-num">3</div>
<div><strong>Sync the Fleet</strong><br><span style="color:#64748b; font-size:13px;">Save the bridge URL, token, and public MeshCentral URL, then run Sync API Data.</span></div>
</div>
</div>
</div>
</div>
<div id="tab-provision" class="gp-tab-content">
<div class="gp-card">
<h3><span class="dashicons dashicons-cloud-saved"></span> Deploy New Agents</h3>
<p style="color:#64748b;">Choose a MeshCentral group, generate platform links, then copy the Linux command to install the agent on Victor or another server.</p>
<div class="gp-grid">
<label class="gp-field"><span>Target Device Group</span>
<select class="gp-input" id="gp-provision-group">
<option value="">Choose a cached MeshCentral group</option>
<?php foreach($cached_groups as $group):
$gid = $group['id'] ?? $group['_id'] ?? '';
if(!$gid) continue;
?>
<option value="<?php echo esc_attr($gid); ?>"><?php echo esc_html($group['name'] ?? $gid); ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="setup-step" style="margin:0;">
<div class="step-num">1</div>
<div><strong>Linux install flow</strong><br><span style="color:#64748b; font-size:13px;">Generate links, copy <code>linux_x64_install_command</code>, paste it into Victor, and then sync the fleet.</span></div>
</div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin:14px 0;">
<button class="gp-btn gp-btn-blue" onclick="gpGenerateProvisionLinks()">Generate Windows / Mac / Linux Links</button>
<button class="gp-btn gp-btn-green" onclick="gpWaitForEnrollment()">Check For New Agent</button>
<button class="gp-btn gp-btn-gray" onclick="gpFetchFleet()">Refresh Fleet After Install</button>
</div>
<div id="gp-enrollment-output" class="gp-agent-note" style="margin-bottom:12px;">Generate an installer, run it on the new machine, then check for the agent in the selected group.</div>
<div id="gp-provision-output"></div>
</div>
</div>
</div>
<script>
const gpMeshAjax = '<?php echo esc_url(admin_url('admin-ajax.php')); ?>';
const gpMeshNonce = '<?php echo wp_create_nonce("gp_mesh_nonce"); ?>';
function gpShowToast(msg, type) {
let toast = document.getElementById('gp-toast');
toast.innerHTML = msg;
toast.className = 'gp-toast ' + type;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3500);
}
function gpSwitchTab(tab, el) {
document.querySelectorAll('.gp-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.gp-tab-content').forEach(c => c.classList.remove('active'));
if(el) el.classList.add('active');
else document.querySelector('[onclick*="'+tab+'"]').classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
}
function gpSaveAuth() {
let host = document.getElementById('gp-mesh-url').value.replace(/\/$/, "");
let key = document.getElementById('gp-mesh-key').value;
let mode = document.getElementById('gp-mesh-mode').value;
let publicUrl = document.getElementById('gp-mesh-public-url').value.replace(/\/$/, "");
let linkMode = document.getElementById('gp-mesh-link-mode').value;
let advancedMode = document.getElementById('gp-mesh-advanced-mode')?.checked ? '1' : '0';
if(!host || !key) return gpShowToast('All fields are required.', 'error');
let fd = new FormData();
fd.append('action', 'gp_mesh_save_auth'); fd.append('nonce', gpMeshNonce);
fd.append('host', host); fd.append('api_key', key);
fd.append('mode', mode); fd.append('public_url', publicUrl);
fd.append('link_mode', linkMode);
fd.append('advanced_mode', advancedMode);
gpShowToast('Saving configuration...', 'info');
fetch(gpMeshAjax, {method:'POST', body:fd}).then(r=>r.json()).then(d=>{
if(d.success) {
gpShowToast('Saved successfully.', 'success');
setTimeout(() => { window.location.reload(); }, 900);
}
});
}
function gpRunHealthCheck() {
const panel = document.getElementById('gp-health-panel');
panel.innerHTML = '<div class="gp-status-tile"><span>Status</span><strong>Checking...</strong></div>';
gpSetOutput('gp-health-output', 'Running bridge and MeshCentral health checks...');
gpMeshPost('gp_mesh_health_check', {}).then(d => {
if(!d.success) {
panel.innerHTML = '<div class="gp-status-tile warn"><span>Status</span><strong>Failed</strong></div>';
gpSetOutput('gp-health-output', d.data || d);
gpShowToast(d.data?.msg || 'Health check failed.', 'error');
return;
}
const checks = d.data.checks || [];
panel.innerHTML = checks.map(check => '<div class="gp-status-tile ' + (check.ok ? 'ok' : 'warn') + '"><span>' + gpEsc(check.label) + '</span><strong title="' + gpEsc(check.detail) + '">' + (check.ok ? 'OK' : 'Needs Work') + '</strong></div>').join('');
gpSetOutput('gp-health-output', d.data);
gpShowToast('Health check complete.', checks.every(check => check.ok) ? 'success' : 'info');
}).catch(e => {
panel.innerHTML = '<div class="gp-status-tile warn"><span>Status</span><strong>Request failed</strong></div>';
gpSetOutput('gp-health-output', 'Request failed: ' + e.message);
});
}
function gpFetchFleet() {
let container = document.getElementById('gp-fleet-container');
container.innerHTML = '<div style="text-align:center; padding:40px; color:#f59e0b; font-weight:bold;">Hitting Custom REST API...</div>';
let fd = new FormData(); fd.append('action', 'gp_mesh_get_fleet'); fd.append('nonce', gpMeshNonce);
fetch(gpMeshAjax, {method:'POST', body:fd}).then(r=>r.json()).then(d=>{
if(d.success) container.innerHTML = d.data.html;
else container.innerHTML = '<div style="color:#ef4444; padding:20px; font-weight:bold; text-align:center;">' + (d.data.msg || 'Unknown Error') + '</div>';
}).catch(e => { container.innerHTML = '<div style="color:#ef4444; padding:20px; font-weight:bold; text-align:center;">Network Timeout or PHP Error. Check server logs.</div>'; });
}
function gpFilterFleet() {
const search = (document.getElementById('gp-fleet-search')?.value || '').toLowerCase();
const status = (document.getElementById('gp-fleet-status')?.value || '').toLowerCase();
const group = (document.getElementById('gp-fleet-group')?.value || '').toLowerCase();
document.querySelectorAll('.gp-device-card').forEach(card => {
const matchesSearch = !search || (card.dataset.search || '').includes(search);
const matchesStatus = !status || card.dataset.status === status;
const matchesGroup = !group || card.dataset.group === group;
card.style.display = matchesSearch && matchesStatus && matchesGroup ? '' : 'none';
});
document.querySelectorAll('.gp-fleet-group').forEach(section => {
const visibleCards = Array.from(section.querySelectorAll('.gp-device-card')).filter(card => card.style.display !== 'none');
const groupMatches = !group || section.dataset.group === group;
section.style.display = groupMatches && visibleCards.length ? '' : 'none';
});
}
function gpMeshAction(nodeId, action) {
if(!action) return;
let msg = '';
if(action === 'message') {
msg = prompt('Enter message to pop up on user\'s screen:');
if(!msg) return;
} else {
if(!confirm('Are you sure you want to execute ' + action + ' on this machine?')) return;
}
let fd = new FormData();
fd.append('action', 'gp_mesh_dispatch'); fd.append('nonce', gpMeshNonce);
fd.append('node_id', nodeId); fd.append('action_type', action); fd.append('message', msg);
gpShowToast('Dispatching command...', 'info');
fetch(gpMeshAjax, {method:'POST', body:fd}).then(r=>r.json()).then(d=>{
if(d.success) gpShowToast('Command sent to MeshCentral.', 'success');
else gpShowToast('Failed to execute command.', 'error');
});
}
function gpMeshPost(action, fields) {
let fd = new FormData();
fd.append('action', action);
fd.append('nonce', gpMeshNonce);
Object.keys(fields || {}).forEach(key => fd.append(key, fields[key]));
return fetch(gpMeshAjax, {method:'POST', body:fd}).then(r => r.json());
}
function gpPretty(data) {
return JSON.stringify(data, null, 2);
}
function gpSetOutput(id, value) {
const el = document.getElementById(id);
if(el) el.textContent = typeof value === 'string' ? value : gpPretty(value);
}
function gpEsc(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function gpRenderLinks(targetId, links) {
const target = document.getElementById(targetId);
if(!target) return;
let html = '';
Object.keys(links || {}).forEach(label => {
const value = links[label];
if(!value) return;
if(typeof value === 'object') {
html += '<div style="margin:10px 0 4px;font-weight:800;color:#64748b;">' + gpEsc(label) + '</div>';
html += gpRenderLinkRows(value);
} else {
html += gpRenderLinkRow(label, value);
}
});
target.innerHTML = html || '<div style="color:#64748b;">No links returned.</div>';
}
function gpBestMeshUrl(links, mode) {
const group = links && links[mode] ? links[mode] : {};
return group.primary || group.gotonode_raw_viewmode_first || group.gotonode_viewmode_first || group.gotonode || group.node_raw_viewmode_first || group.node_viewmode_first || group.node || '';
}
function gpOpenMeshSession(nodeId, mode) {
gpShowToast('Generating MeshCentral ' + mode + ' session link...', 'info');
gpMeshPost('gp_mesh_device_links', {node_id: nodeId}).then(d => {
if(!d.success) {
gpShowToast(d.data?.msg || 'Could not generate session link.', 'error');
gpSetOutput('gp-diagnostics-output', d.data || d);
return;
}
const url = gpBestMeshUrl(d.data.links || {}, mode);
if(!url) {
gpShowToast('Bridge returned no ' + mode + ' URL.', 'error');
gpSetOutput('gp-diagnostics-output', d.data);
return;
}
window.open(url, '_blank');
});
}
function gpDiagnoseMeshSession(nodeId) {
gpSetOutput('gp-diagnostics-output', 'Building MeshCentral session diagnostics...');
gpMeshPost('gp_mesh_session_diagnostics', {node_id: nodeId}).then(d => {
gpSetOutput('gp-diagnostics-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'Session diagnostics ready.' : (d.data?.msg || 'Session diagnostics failed.'), d.success ? 'success' : 'error');
gpSwitchTab('troubleshooting');
});
}
function gpRenderLinkRows(group) {
let html = '';
Object.keys(group || {}).forEach(label => {
if(['mode', 'viewmode', 'node_id'].includes(label)) return;
html += gpRenderLinkRow(label, group[label]);
});
return html;
}
function gpRenderLinkRow(label, url) {
const safeUrl = gpEsc(url);
const open = /^https?:\/\//.test(String(url || '')) ? '<a class="gp-btn gp-btn-blue" target="_blank" href="' + safeUrl + '">Open</a>' : '';
return '<div class="gp-link-row"><strong>' + gpEsc(label).replace(/_/g, ' ') + '</strong><input readonly value="' + safeUrl + '">' + open + '<button type="button" class="gp-btn gp-btn-gray" onclick="navigator.clipboard.writeText(this.parentNode.querySelector(&quot;input&quot;).value)">Copy</button></div>';
}
function gpLabelFromKey(key) {
const labels = {
meshcentral_group: 'Open MeshCentral Group',
all_agent_downloads: 'All Agent Downloads',
mesh_assistant_downloads: 'Mesh Assistant',
windows_x64_agent: 'Windows x64 Agent',
windows_x86_agent: 'Windows x86 Agent',
windows_arm64_agent: 'Windows ARM64 Agent',
windows_x64_interactive: 'Windows x64 Interactive Agent',
linux_x64_agent: 'Linux x64 Agent',
linux_x64_install_script: 'Linux x64 Install Script',
linux_arm64_agent_best_effort: 'Linux ARM64 Agent',
macos_x64_agent_best_effort: 'macOS x64 Agent'
};
return labels[key] || String(key || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function gpCopyText(text) {
navigator.clipboard.writeText(String(text || ''));
gpShowToast('Copied.', 'success');
}
let gpLastAgentLinks = {};
function gpAgentSelectableKeys(links) {
const preferred = [
'windows_x64_agent',
'windows_x86_agent',
'windows_arm64_agent',
'windows_x64_interactive',
'mesh_assistant_downloads',
'all_agent_downloads',
'meshcentral_group',
'linux_x64_agent',
'linux_x64_install_script',
'linux_arm64_agent_best_effort',
'macos_x64_agent_best_effort'
];
return preferred.filter(key => links && links[key]);
}
function gpCopyAgentCommand() {
gpCopyText(gpLastAgentLinks.linux_x64_install_command || '');
}
function gpOpenSelectedAgentLink(selectId) {
const select = document.getElementById(selectId);
const key = select ? select.value : '';
const url = gpLastAgentLinks[key] || '';
if(/^https?:\/\//.test(String(url))) window.open(url, '_blank');
else gpShowToast('Choose a downloadable/openable agent link first.', 'error');
}
function gpCopySelectedAgentLink(selectId) {
const select = document.getElementById(selectId);
const key = select ? select.value : '';
const url = gpLastAgentLinks[key] || '';
if(url) gpCopyText(url);
else gpShowToast('Choose an agent link first.', 'error');
}
function gpRenderAgentLinks(targetId, links) {
const target = document.getElementById(targetId);
if(!target) return;
links = links || {};
gpLastAgentLinks = links;
const linuxCommand = links.linux_x64_install_command || '';
const meshId = links.mesh_id || '';
const shortMeshId = links.short_mesh_id || '';
const selectorId = targetId + '-agent-link-picker';
const selectableKeys = gpAgentSelectableKeys(links);
let html = '<div class="gp-agent-panel">';
html += '<div class="gp-agent-note"><strong>How this works:</strong> MeshCentral uses a hidden Mesh ID to attach a new agent to the selected device group. MeshPress already includes that ID in every link below, so your normal workflow is just copy the Linux command or open the installer link you need.</div>';
html += '<div class="gp-agent-primary">';
html += '<h4>Linux one-line install</h4>';
html += '<div class="gp-agent-summary">';
html += '<span class="gp-agent-pill">Group: ' + gpEsc(links.group_name || 'Selected group') + '</span>';
html += '<span class="gp-agent-pill">Mesh ID: ' + gpEsc(shortMeshId ? shortMeshId.slice(0, 10) + '...' : 'resolved by bridge') + '</span>';
if(links.installer_verified_shape) {
html += '<span class="gp-agent-pill">Length: ' + gpEsc(String(links.installer_verified_shape.short_mesh_id_length || 0)) + '</span>';
}
html += '</div>';
if(linuxCommand) {
html += '<small>Run this on the Linux machine you want to enroll. It mirrors MeshCentral native installer syntax and passes the selected group id as the second argument.</small>';
html += '<div class="gp-command-box" style="margin-top:10px;"><code>' + gpEsc(linuxCommand) + '</code><button type="button" class="gp-btn gp-btn-blue" onclick="gpCopyAgentCommand()">Copy Command</button></div>';
} else {
html += '<div class="gp-agent-note">The bridge did not return a Linux install command. Check bridge logs for the <code>agent_links</code> event.</div>';
}
html += '</div>';
if(links.agent_link_warning) {
html += '<div class="gp-agent-note" style="border-color:#fbbf24;background:#fffbeb;color:#92400e;"><strong>Warning:</strong> ' + gpEsc(links.agent_link_warning) + '</div>';
}
html += '<div class="gp-agent-picker">';
html += '<label class="gp-field" style="margin:0;"><span>Other installer / support link</span><select class="gp-input" id="' + gpEsc(selectorId) + '">';
selectableKeys.forEach(key => { html += '<option value="' + gpEsc(key) + '">' + gpEsc(gpLabelFromKey(key)) + '</option>'; });
html += '</select></label>';
html += '<div class="gp-agent-note" style="margin:0;">Use this compact picker for Windows, macOS, Mesh Assistant, or raw download links. Nothing downloads unless you press Open.</div>';
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;"><button type="button" class="gp-btn gp-btn-blue" onclick="gpOpenSelectedAgentLink(&quot;' + gpEsc(selectorId) + '&quot;)">Open</button><button type="button" class="gp-btn gp-btn-gray" onclick="gpCopySelectedAgentLink(&quot;' + gpEsc(selectorId) + '&quot;)">Copy Link</button></div>';
html += '</div>';
html += '<details class="gp-advanced"><summary>Advanced raw identifiers and diagnostics</summary><div class="gp-link-row"><strong>Mesh ID</strong><input readonly value="' + gpEsc(meshId) + '"><button type="button" class="gp-btn gp-btn-gray" onclick="gpCopyText(this.parentNode.querySelector(&quot;input&quot;).value)">Copy</button></div><div class="gp-output" style="min-height:80px;max-height:220px;">' + gpEsc(gpPretty(links)) + '</div></details>';
html += '</div>';
target.innerHTML = html;
}
function gpUpdateGroupOptionLabels(group) {
if(!group || !group.id || !group.name) return;
document.querySelectorAll('select option').forEach(option => {
if(option.value === group.id || option.value.split('/').pop() === String(group.id).split('/').pop()) {
option.textContent = group.name;
}
});
}
function gpRenderBridgeStatus(data) {
const target = document.getElementById('gp-bridge-status');
if(!target) return;
const tiles = [
['Runtime', data.runtime_version || data.version || 'unknown'],
['Installed', data.installed_version || 'unknown'],
['Updater', data.update_script_found ? 'Available' : 'Missing'],
['Directory', data.directory || 'unknown'],
['Uptime', (data.uptime_seconds || 0) + ' sec'],
['Catalog', data.catalog_url || 'default']
];
target.innerHTML = tiles.map(([label, value]) => '<div class="gp-status-tile"><span>' + gpEsc(label) + '</span><strong title="' + gpEsc(value) + '">' + gpEsc(value) + '</strong></div>').join('');
}
function gpCheckBridgeVersion() {
gpMeshPost('gp_mesh_bridge_probe', {probe:'version'}).then(d => {
if(d.success) {
gpRenderBridgeStatus(d.data);
gpSetOutput('gp-diagnostics-output', d.data);
gpShowToast('Bridge version checked.', 'success');
} else {
document.getElementById('gp-bridge-status').innerHTML = '<div class="gp-status-tile"><span>Status</span><strong>Unavailable</strong></div>';
gpShowToast(d.data?.msg || 'Bridge version check failed.', 'error');
}
});
}
function gpUpdateBridge() {
if(!confirm('Trigger the MeshPress Bridge updater now? The bridge may restart if an update is found.')) return;
document.getElementById('gp-bridge-update-output').innerHTML = '<div class="gp-output" style="min-height:80px;">Starting bridge update...</div>';
gpMeshPost('gp_mesh_update_bridge', {}).then(d => {
gpSetOutput('gp-bridge-update-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'Bridge update started.' : (d.data?.msg || 'Bridge update failed.'), d.success ? 'success' : 'error');
setTimeout(gpCheckBridgeVersion, 3500);
});
}
function gpMaintenanceProbe() {
gpSetOutput('gp-maintenance-output', 'Reading MeshCentral maintenance status...');
gpMeshPost('gp_mesh_maintenance_probe', {}).then(d => {
gpSetOutput('gp-maintenance-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'MeshCentral status ready.' : (d.data?.msg || 'MeshCentral status failed.'), d.success ? 'success' : 'error');
}).catch(e => gpSetOutput('gp-maintenance-output', 'Request failed: ' + e.message));
}
function gpToggleMaintenance(enabled) {
const label = enabled ? 'enable' : 'disable';
const detail = enabled
? 'This unlocks guarded bridge actions such as MeshCentral backup/update tests.'
: 'This locks guarded bridge actions again.';
if(!confirm('Really ' + label + ' MeshPress bridge maintenance mode? ' + detail)) return;
gpSetOutput('gp-maintenance-output', 'Requesting maintenance mode ' + label + '...');
gpMeshPost('gp_mesh_toggle_maintenance', {enabled: enabled ? '1' : '0'}).then(d => {
gpSetOutput('gp-maintenance-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'Maintenance mode ' + (enabled ? 'enabled.' : 'disabled.') : (d.data?.msg || 'Maintenance toggle failed.'), d.success ? 'success' : 'error');
}).catch(e => gpSetOutput('gp-maintenance-output', 'Request failed: ' + e.message));
}
function gpToggleMeshCentralMaintenance(enabled) {
const label = enabled ? 'enable' : 'disable';
const warning = enabled
? 'This writes settings.maintenanceMode=true to MeshCentral config.json and restarts MeshCentral.'
: 'This writes settings.maintenanceMode=false to MeshCentral config.json and restarts MeshCentral.';
if(!confirm('Really ' + label + ' MeshCentral maintenance mode? ' + warning)) return;
gpSetOutput('gp-maintenance-output', 'Requesting MeshCentral maintenance ' + label + '...');
gpMeshPost('gp_mesh_toggle_meshcentral_maintenance', {enabled: enabled ? '1' : '0'}).then(d => {
gpSetOutput('gp-maintenance-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'MeshCentral maintenance ' + (enabled ? 'enabled.' : 'disabled.') : (d.data?.msg || 'MeshCentral maintenance toggle failed.'), d.success ? 'success' : 'error');
if(d.success) setTimeout(gpMaintenanceProbe, 1200);
}).catch(e => gpSetOutput('gp-maintenance-output', 'Request failed: ' + e.message));
}
function gpMeshCentralBackup(stopService) {
const detail = stopService
? 'This stops MeshCentral for the most consistent file snapshot, dumps the database, then starts it again.'
: 'This keeps MeshCentral running and uses a transactional database dump. File state can move during the backup.';
if(!confirm('Create a full MeshCentral backup through the bridge? ' + detail)) return;
gpSetOutput('gp-backup-output', 'Starting MeshCentral full backup request...');
gpMeshPost('gp_meshcentral_backup', {stop_service: stopService ? '1' : '0'}).then(d => {
gpSetOutput('gp-backup-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'MeshCentral backup requested.' : (d.data?.msg || 'MeshCentral backup failed.'), d.success ? 'success' : 'error');
if(d.success) setTimeout(gpMaintenanceProbe, 1200);
}).catch(e => gpSetOutput('gp-backup-output', 'Request failed: ' + e.message));
}
function gpConfigAudit() {
gpSetOutput('gp-backup-output', 'Reading and redacting MeshCentral config audit...');
gpMeshPost('gp_meshcentral_config_audit', {}).then(d => {
gpSetOutput('gp-backup-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'Config audit ready.' : (d.data?.msg || 'Config audit failed.'), d.success ? 'success' : 'error');
}).catch(e => gpSetOutput('gp-backup-output', 'Request failed: ' + e.message));
}
function gpMeshCentralUpdate() {
if(!confirm('Trigger a MeshCentral npm/package update from the bridge? Use this only after backups are confirmed.')) return;
gpSetOutput('gp-maintenance-output', 'Starting MeshCentral update request...');
gpMeshPost('gp_meshcentral_update', {}).then(d => {
gpSetOutput('gp-maintenance-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'MeshCentral update requested.' : (d.data?.msg || 'MeshCentral update failed.'), d.success ? 'success' : 'error');
}).catch(e => gpSetOutput('gp-maintenance-output', 'Request failed: ' + e.message));
}
function gpBridgeProbe(probe) {
gpSetOutput('gp-diagnostics-output', 'Running ' + probe + '...');
gpMeshPost('gp_mesh_bridge_probe', {probe}).then(d => {
gpSetOutput('gp-diagnostics-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'Probe complete.' : (d.data?.msg || 'Probe failed.'), d.success ? 'success' : 'error');
}).catch(e => gpSetOutput('gp-diagnostics-output', 'Request failed: ' + e.message));
}
function gpCreateManualGroup() {
const name = document.getElementById('gp-manual-group-name').value;
const group_description = document.getElementById('gp-manual-group-description').value;
if(!name) return gpShowToast('Group name is required.', 'error');
gpMeshPost('gp_mesh_create_group', {group_name:name, group_description}).then(d => {
document.getElementById('gp-group-output').innerHTML = d.success ? '<div style="color:#10b981;font-weight:800;margin-bottom:8px;">Group created.</div>' : '<div style="color:#ef4444;font-weight:800;">' + gpEsc(d.data?.msg || 'Group creation failed.') + '</div>';
if(d.success) gpRenderLinks('gp-group-output', d.data.links || {});
});
}
function gpEditMeshGroup() {
const group_id = document.getElementById('gp-edit-group-id').value;
const group_name = document.getElementById('gp-edit-group-name').value;
const group_description = document.getElementById('gp-edit-group-description').value;
if(!group_id) return gpShowToast('Choose a group first.', 'error');
if(!group_name && !group_description) return gpShowToast('Enter a new name or description.', 'error');
document.getElementById('gp-edit-group-output').innerHTML = '<div class="gp-output">Saving group changes and verifying MeshCentral refreshed data...</div>';
gpMeshPost('gp_mesh_edit_group', {group_id, group_name, group_description}).then(d => {
if(d.success) {
gpUpdateGroupOptionLabels(d.data.updated_group || d.data.matched_group || {});
const updated = d.data.updated_group || {};
document.getElementById('gp-edit-group-output').innerHTML = '<div class="gp-agent-note"><strong>Verified group update.</strong> MeshCentral refreshed the group record' + (updated.name ? ' as <strong>' + gpEsc(updated.name) + '</strong>' : '') + '.</div><details class="gp-advanced" style="margin-top:10px;"><summary>Raw bridge response</summary><div class="gp-output">' + gpEsc(gpPretty(d.data)) + '</div></details>';
gpShowToast('Group update verified.', 'success');
return;
}
document.getElementById('gp-edit-group-output').innerHTML = '<div style="color:#ef4444;font-weight:800;margin-bottom:8px;">' + gpEsc(d.data?.msg || 'Group update failed.') + '</div><div class="gp-output">' + gpEsc(gpPretty(d.data || d)) + '</div>';
gpShowToast(d.data?.msg || 'Group update failed.', 'error');
});
}
function gpResolveMeshGroup() {
const group_id = document.getElementById('gp-edit-group-id').value;
if(!group_id) return gpShowToast('Choose a group first.', 'error');
gpMeshPost('gp_mesh_resolve_group', {group_id}).then(d => {
document.getElementById('gp-edit-group-output').innerHTML = '<div class="gp-output">' + gpEsc(gpPretty(d.success ? d.data : d.data || d)) + '</div>';
gpShowToast(d.success ? 'Group resolver output ready.' : (d.data?.msg || 'Group resolver failed.'), d.success ? 'success' : 'error');
});
}
function gpGenerateAgentLinks() {
const group_id = document.getElementById('gp-agent-group').value;
if(!group_id) return gpShowToast('Choose a group first.', 'error');
document.getElementById('gp-agent-output').innerHTML = '<div class="gp-output" style="min-height:80px;">Generating group-specific installer links...</div>';
gpMeshPost('gp_mesh_agent_links', {group_id}).then(d => {
if(!d.success) return document.getElementById('gp-agent-output').innerHTML = '<div style="color:#ef4444;font-weight:800;">' + gpEsc(d.data?.msg || 'Could not generate links.') + '</div>';
gpRenderAgentLinks('gp-agent-output', d.data.links || {});
});
}
function gpGenerateProvisionLinks() {
const group_id = document.getElementById('gp-provision-group').value;
if(!group_id) return gpShowToast('Choose a group first.', 'error');
document.getElementById('gp-provision-output').innerHTML = '<div class="gp-output">Generating platform agent links...</div>';
gpMeshPost('gp_mesh_agent_links', {group_id}).then(d => {
if(!d.success) {
document.getElementById('gp-provision-output').innerHTML = '<div style="color:#ef4444;font-weight:800;">' + gpEsc(d.data?.msg || 'Could not generate links.') + '</div>';
return;
}
gpRenderAgentLinks('gp-provision-output', d.data.links || {});
});
}
function gpWaitForEnrollment() {
const group_id = document.getElementById('gp-provision-group').value;
const target = document.getElementById('gp-enrollment-output');
if(!group_id) return gpShowToast('Choose a group first.', 'error');
target.innerHTML = 'Refreshing MeshCentral fleet and checking this group...';
gpMeshPost('gp_mesh_wait_enrollment', {group_id}).then(d => {
if(!d.success) {
target.innerHTML = '<strong>Enrollment check failed.</strong> ' + gpEsc(d.data?.msg || 'Bridge did not return fleet data.');
gpShowToast(d.data?.msg || 'Enrollment check failed.', 'error');
return;
}
const matches = d.data.matches || [];
if(matches.length) {
target.innerHTML = '<strong>Agent group has ' + matches.length + ' device(s).</strong> Latest visible devices: ' + gpEsc(matches.slice(0, 5).map(device => device.name || device.id || 'Unnamed').join(', '));
gpShowToast('Enrollment check found devices in this group.', 'success');
} else {
target.innerHTML = '<strong>No devices are visible in that group yet.</strong> Keep the installer running, then check again.';
gpShowToast('No matching agent yet.', 'info');
}
}).catch(e => {
target.innerHTML = '<strong>Request failed.</strong> ' + gpEsc(e.message);
});
}
function gpGenerateDeviceLinks() {
const node_id = document.getElementById('gp-device-node').value;
if(!node_id) return gpShowToast('Choose a device first.', 'error');
gpMeshPost('gp_mesh_device_links', {node_id}).then(d => {
if(!d.success) return document.getElementById('gp-device-output').innerHTML = '<div style="color:#ef4444;font-weight:800;">' + gpEsc(d.data?.msg || 'Could not generate device links.') + '</div>';
gpRenderLinks('gp-device-output', d.data.links || {});
});
}
function gpOpenSelectedConsole() {
const node_id = document.getElementById('gp-device-node').value;
if(!node_id) return gpShowToast('Choose a device first.', 'error');
gpOpenMeshSession(node_id, 'console');
}
function gpDiagnoseSelectedDevice() {
const node_id = document.getElementById('gp-device-node').value;
if(!node_id) return gpShowToast('Choose a device first.', 'error');
gpDiagnoseMeshSession(node_id);
}
function gpGetDeviceInfo() {
const node_id = document.getElementById('gp-device-node').value;
if(!node_id) return gpShowToast('Choose a device first.', 'error');
document.getElementById('gp-device-output').innerHTML = '<div class="gp-output">Reading device info...</div>';
gpMeshPost('gp_mesh_device_info', {node_id}).then(d => {
document.getElementById('gp-device-output').innerHTML = '<div class="gp-output">' + gpEsc(gpPretty(d.success ? d.data : d)) + '</div>';
});
}
function gpRenameDevice() {
const node_id = document.getElementById('gp-device-node').value;
const device_name = document.getElementById('gp-rename-device-name').value;
if(!node_id || !device_name) return gpShowToast('Choose a device and enter a new name.', 'error');
if(!confirm('Rename this MeshCentral device?')) return;
gpMeshPost('gp_mesh_rename_device', {node_id, device_name}).then(d => {
document.getElementById('gp-device-output').innerHTML = '<div class="gp-output">' + gpEsc(gpPretty(d.success ? d.data : d.data || d)) + '</div>';
gpShowToast(d.success ? 'Device rename requested.' : (d.data?.msg || 'Device rename failed.'), d.success ? 'success' : 'error');
});
}
function gpMoveDevice() {
const node_id = document.getElementById('gp-device-node').value;
const group_id = document.getElementById('gp-move-device-group').value;
if(!node_id || !group_id) return gpShowToast('Choose a device and destination group.', 'error');
if(!confirm('Move this MeshCentral device to the selected group?')) return;
gpMeshPost('gp_mesh_move_device', {node_id, group_id}).then(d => {
document.getElementById('gp-device-output').innerHTML = '<div class="gp-output">' + gpEsc(gpPretty(d.success ? d.data : d.data || d)) + '</div>';
gpShowToast(d.success ? 'Device move requested.' : (d.data?.msg || 'Device move failed.'), d.success ? 'success' : 'error');
});
}
function gpUninstallAgent() {
const node_id = document.getElementById('gp-agent-lifecycle-node').value;
if(!node_id) return gpShowToast('Choose a device first.', 'error');
if(!confirm('Request MeshCentral agent uninstall for this device?')) return;
gpSetOutput('gp-agent-lifecycle-output', 'Requesting agent uninstall diagnostics...');
gpMeshPost('gp_mesh_uninstall_agent', {node_id}).then(d => {
gpSetOutput('gp-agent-lifecycle-output', d.success ? d.data : d.data || d);
gpShowToast(d.success ? 'Agent uninstall command accepted.' : (d.data?.msg || 'Agent uninstall failed.'), d.success ? 'success' : 'error');
}).catch(e => gpSetOutput('gp-agent-lifecycle-output', 'Request failed: ' + e.message));
}
function gpUninstallSelectedAgent() {
const source = document.getElementById('gp-device-node');
const target = document.getElementById('gp-agent-lifecycle-node');
if(!source || !source.value) return gpShowToast('Choose a device first.', 'error');
if(target) target.value = source.value;
gpSwitchTab('ops');
gpUninstallAgent();
}
function gpClearMeshCache() {
gpMeshPost('gp_mesh_clear_cache', {}).then(d => {
gpShowToast(d.success ? 'Cache cleared.' : 'Cache clear failed.', d.success ? 'success' : 'error');
gpSetOutput('gp-diagnostics-output', d.success ? d.data : d);
});
}
function gpPushNow() {
gpSetOutput('gp-diagnostics-output', 'Requesting bridge push...');
gpMeshPost('gp_mesh_push_now', {}).then(d => {
gpShowToast(d.success ? 'Fleet pushed to WordPress.' : (d.data?.msg || 'Push failed.'), d.success ? 'success' : 'error');
gpSetOutput('gp-diagnostics-output', d.success ? d.data : d);
});
}
// Boot
<?php if(!empty($host) && !empty($key)) echo "gpFetchFleet();"; ?>
</script>
<?php
}
}
new GracePress_MeshPress();
}
// =============================================================================
// 6. BIFROST UPDATE ENGINE
// =============================================================================
if (!class_exists('GP_Mesh_UE')) {
class GP_Mesh_UE {
public function __construct() {
add_filter('site_transient_update_plugins', [$this, 'cu']);
}
public function cu($t) {
if (empty($t->checked)) return $t;
$s = 'mesh-press/mesh-press.php';
if (!function_exists('get_plugin_data')) include_once(ABSPATH . 'wp-admin/includes/plugin.php');
$p = file_exists(WP_PLUGIN_DIR . '/' . $s) ? get_plugin_data(WP_PLUGIN_DIR . '/' . $s) : [];
$r = wp_remote_get('https://christit.com/gracepress/api.php?endpoint=manifest&slug=mesh-press&nocache=' . time(), ['timeout' => 10]);
if (!is_wp_error($r) && wp_remote_retrieve_response_code($r) == 200) {
$d = json_decode(wp_remote_retrieve_body($r));
if ($d && version_compare(trim($p['Version'] ?? '0.0.0'), trim($d->version ?? '0.0.0'), '<')) {
$x = new stdClass();
$x->slug = dirname($s); $x->plugin = $s; $x->new_version = $d->version;
$x->package = $d->download_url; $x->url = 'https://christit.com/gracepress/';
$x->tested = '6.4'; $x->requires = '6.0'; $x->requires_php = '8.0';
$t->response[$s] = $x;
}
}
return $t;
}
}
new GP_Mesh_UE();
}