Connector Open-Source Code

Browse connector files locally. Exchange API keys stay on your device.

ui/index.php

<?php
// /opt/nuxvision_connector/ui/index.php
declare(strict_types=1);

require_once __DIR__ . '/common.php';

function sudo_systemctl(string $cmd, string $unit): array {
    $cmd = trim($cmd);
    if (!in_array($cmd, ['start','stop','is-active'], true)) {
        return [false, 1, 'bad_command'];
    }

    // nuxvision-runner@1.service | nuxvision-tracker@1.service
    if (!preg_match('/^nuxvision-(runner|tracker)@\d+\.service$/', $unit)) {
        return [false, 1, 'bad_unit'];
    }

    $full = 'sudo -n /bin/systemctl ' . escapeshellarg($cmd) . ' ' . escapeshellarg($unit) . ' 2>&1';
    $out = [];
    $rc = 0;
    exec($full, $out, $rc);
    return [$rc === 0, $rc, trim(implode("\n", $out))];
}

function connector_app_dir(): string {
    global $BASE_DIR;
    return rtrim((string)$BASE_DIR, '/');
}

function connector_local_version(): string {
    $appDir = connector_app_dir();
    $candidates = [
        $appDir . '/VERSION',
        $appDir . '/version.txt',
    ];
    foreach ($candidates as $path) {
        if (!is_file($path)) continue;
        $v = trim((string)@file_get_contents($path));
        if ($v !== '') return $v;
    }
    return '0';
}

function connector_fetch_remote_version(int $timeout = 6): array {
    $url = 'https://nuxvision.com/nuxvision_connector/version.txt';
    $ch = curl_init();
    if (!$ch) return [false, '', 'curl_init_failed'];

    curl_setopt_array($ch, [
        CURLOPT_URL => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_CONNECTTIMEOUT => min(3, $timeout),
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 2,
        CURLOPT_USERAGENT => 'nuxvision-connector-ui/1.0',
    ]);

    $body = curl_exec($ch);
    $err = (string)curl_error($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($err !== '') return [false, '', $err];
    if ($code < 200 || $code >= 400) return [false, '', 'http_' . $code];
    if (!is_string($body)) return [false, '', 'empty_body'];

    $v = trim(str_replace("\r", '', $body));
    if (strpos($v, "\n") !== false) {
        $v = trim((string)explode("\n", $v, 2)[0]);
    }
    if ($v === '') return [false, '', 'empty_version'];
    return [true, $v, ''];
}

function connector_check_update_status(int $timeout = 6): array {
    $local = connector_local_version();
    [$ok, $remote, $err] = connector_fetch_remote_version($timeout);

    $status = [
        'checked_at' => date('Y-m-d H:i:s'),
        'local_version' => $local,
        'remote_version' => '',
        'state' => 'check_failed',
        'message' => 'Unable to check updates.',
    ];

    if (!$ok) {
        $status['message'] = 'Update check failed: ' . ($err !== '' ? $err : 'unknown_error');
        return $status;
    }

    $status['remote_version'] = $remote;
    $cmp = version_compare($remote, $local);
    if ($cmp > 0) {
        $status['state'] = 'update_available';
        $status['message'] = "Update available: {$local} -> {$remote}";
    } elseif ($cmp === 0) {
        $status['state'] = 'up_to_date';
        $status['message'] = "Connector is up to date ({$local}).";
    } else {
        $status['state'] = 'local_newer';
        $status['message'] = "Local version is newer ({$local}) than server ({$remote}).";
    }

    return $status;
}

function connector_run_update_script(): array {
    $script = connector_app_dir() . '/update.sh';
    if (!is_file($script)) {
        return [false, "Update script not found: {$script}"];
    }

    $cmd = 'sudo -n ' . escapeshellarg($script) . ' 2>&1';
    $out = [];
    $rc = 0;
    exec($cmd, $out, $rc);
    $text = trim(implode("\n", $out));
    return [$rc === 0, $text !== '' ? $text : ($rc === 0 ? 'ok' : 'error')];
}

function connector_one_line(string $text, int $maxLen = 220): string {
    $line = trim((string)preg_replace('/\s+/', ' ', $text));
    if ($line === '') return '';
    if (strlen($line) <= $maxLen) return $line;
    return substr($line, 0, $maxLen - 3) . '...';
}

function list_local_instances(string $instancesDir): array {
    $out = [];
    if (!is_dir($instancesDir)) return $out;

    $dh = opendir($instancesDir);
    if (!$dh) return $out;

    while (($entry = readdir($dh)) !== false) {
        if ($entry === '.' || $entry === '..') continue;
        if (!ctype_digit($entry)) continue;

        $id = (int)$entry;
        if ($id <= 0) continue;

        $cfgPath = rtrim($instancesDir, '/') . '/' . $entry . '/config.php';
        if (!is_file($cfgPath)) continue;

        $cfg = @require $cfgPath;
        if (!is_array($cfg)) continue;

        $out[] = [
            'id'       => $id,
            'cfg_path' => $cfgPath,
            'exchange' => (string)($cfg['exchange']['name'] ?? ''),
        ];
    }
    closedir($dh);

    usort($out, fn($a,$b) => $a['id'] <=> $b['id']);
    return $out;
}

function service_state_for_unit(string $unit): array {
    [, , $outActive] = sudo_systemctl('is-active', $unit);
    $state = trim((string)$outActive);
    $isRunning = ($state === 'active');
    if ($state === '') $state = 'unknown';
    return [$state, $isRunning];
}

function classify_instance_status(array $services): array {
    $runnerRunning = !empty($services[0]['running']);
    $trackerRunning = !empty($services[1]['running']);

    $knownGood = ['active', 'inactive'];
    $hasError = false;

    foreach ($services as $svc) {
        $state = trim((string)($svc['state'] ?? 'unknown'));
        if (!in_array($state, $knownGood, true)) {
            $hasError = true;
            break;
        }
    }

    if ($hasError) {
        return ['key' => 'error', 'label' => 'Error', 'badge' => 'status-error'];
    }
    if ($runnerRunning && $trackerRunning) {
        return ['key' => 'running', 'label' => 'Running', 'badge' => 'status-running'];
    }
    if (!$runnerRunning && !$trackerRunning) {
        return ['key' => 'stopped', 'label' => 'Stopped', 'badge' => 'status-stopped'];
    }
    return ['key' => 'degraded', 'label' => 'Degraded', 'badge' => 'status-degraded'];
}

function unit_main_pid(string $unit): int {
    if (!preg_match('/^nuxvision-(runner|tracker)@\d+\.service$/', $unit)) {
        return 0;
    }
    $cmd = '/bin/systemctl show ' . escapeshellarg($unit) . ' --property MainPID --value 2>/dev/null';
    $out = [];
    $rc = 0;
    exec($cmd, $out, $rc);
    if ($rc !== 0 || !isset($out[0])) return 0;
    $pid = trim((string)$out[0]);
    return ctype_digit($pid) ? (int)$pid : 0;
}

function pid_usage_snapshot(int $pid): array {
    if ($pid <= 0) return ['cpu_pct' => 0.0, 'rss_kb' => 0];
    $cmd = 'ps -p ' . (int)$pid . ' -o %cpu=,rss= 2>/dev/null';
    $out = [];
    $rc = 0;
    exec($cmd, $out, $rc);
    if ($rc !== 0 || !isset($out[0])) return ['cpu_pct' => 0.0, 'rss_kb' => 0];

    $line = trim((string)$out[0]);
    if ($line === '') return ['cpu_pct' => 0.0, 'rss_kb' => 0];
    $parts = preg_split('/\s+/', $line);
    if (!is_array($parts) || count($parts) < 2) return ['cpu_pct' => 0.0, 'rss_kb' => 0];

    $cpu = is_numeric($parts[0]) ? (float)$parts[0] : 0.0;
    $rss = is_numeric($parts[1]) ? (int)$parts[1] : 0;
    return ['cpu_pct' => max(0.0, $cpu), 'rss_kb' => max(0, $rss)];
}

function kb_to_mb_str(int $kb): string {
    $mb = $kb / 1024;
    return number_format($mb, 1, '.', '');
}

function nv_tail_lines(string $path, int $lineCount = 30): array {
    $lineCount = max(1, min(200, $lineCount));
    if (!is_file($path) || !is_readable($path)) return [];

    $cmd = 'tail -n ' . (int)$lineCount . ' ' . escapeshellarg($path) . ' 2>/dev/null';
    $out = [];
    $rc = 0;
    exec($cmd, $out, $rc);
    if ($rc === 0 && is_array($out) && $out) return $out;

    $all = @file($path, FILE_IGNORE_NEW_LINES);
    if (!is_array($all) || !$all) return [];
    return array_slice($all, -$lineCount);
}

function nv_collect_recent_activity(array $instances, int $maxItems = 14): array {
    $rows = [];

    foreach ($instances as $it) {
        $instanceId = (int)($it['id'] ?? 0);
        if ($instanceId <= 0) continue;

        $cfgPath = (string)($it['cfg_path'] ?? '');
        if ($cfgPath === '') continue;
        $instanceDir = dirname($cfgPath);
        if ($instanceDir === '' || $instanceDir === '.') continue;

        $logs = [
            'Runner' => $instanceDir . '/runner.log',
            'Tracker' => $instanceDir . '/tracker.log',
        ];

        foreach ($logs as $svc => $path) {
            $lines = nv_tail_lines($path, 28);
            foreach ($lines as $line) {
                $line = trim((string)$line);
                if ($line === '') continue;

                if (!preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+(.*)$/', $line, $m)) {
                    continue;
                }
                $ts = strtotime($m[1]);
                if ($ts === false) continue;

                $msg = trim((string)$m[2]);
                if ($msg === '') continue;
                if (preg_match('/timed?\s*out|timeout/i', $msg)) continue;

                $level = 'INFO';
                if (preg_match('/\b(TRACE|DEBUG|INFO|WARN|ERROR)\b/i', $msg, $lm)) {
                    $level = strtoupper((string)$lm[1]);
                }

                $summary = preg_replace('/^\d{2}:\d{2}:\d{2}\s+/', '', $msg);
                $summary = is_string($summary) ? $summary : $msg;
                $summary = preg_replace('/^\[[^\]]+\]\s*/', '', $summary);
                $summary = is_string($summary) ? $summary : $msg;
                $summary = preg_replace('/\b(TRACE|DEBUG|INFO|WARN|ERROR)\b\s*/i', '', $summary, 1);
                $summary = is_string($summary) ? $summary : $msg;
                $jsonPos = strpos($summary, '{');
                if ($jsonPos !== false) {
                    $summary = trim(substr($summary, 0, $jsonPos));
                }
                $summary = trim(preg_replace('/\s+/', ' ', $summary) ?? '');
                if ($summary === '') $summary = 'Log event';
                if (strlen($summary) > 140) $summary = substr($summary, 0, 137) . '...';

                $rows[] = [
                    'ts' => (int)$ts,
                    'time' => date('H:i:s', (int)$ts),
                    'instance_id' => $instanceId,
                    'service' => $svc,
                    'level' => $level,
                    'summary' => $summary,
                    'msg' => $msg,
                ];
            }
        }
    }

    usort($rows, static function (array $a, array $b): int {
        return (int)($b['ts'] ?? 0) <=> (int)($a['ts'] ?? 0);
    });

    return array_slice($rows, 0, max(1, $maxItems));
}

/* -------------------- POST actions -------------------- */
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
    $do = (string)($_POST['do'] ?? '');
    $id = to_int($_POST['instance_id'] ?? 0, 0);
    $svc = (string)($_POST['service'] ?? ''); // runner|tracker

    if ($do === 'check_update_silent') {
        $status = connector_check_update_status(2);
        $_SESSION['connector_update_status'] = $status;

        header('Content-Type: application/json; charset=utf-8');
        echo json_encode([
            'ok' => true,
            'state' => (string)($status['state'] ?? ''),
            'local_version' => (string)($status['local_version'] ?? ''),
            'remote_version' => (string)($status['remote_version'] ?? ''),
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    if ($do === 'check_update') {
        $status = connector_check_update_status();
        $_SESSION['connector_update_status'] = $status;

        $state = (string)($status['state'] ?? '');
        $type = ($state === 'update_available') ? 'warning' : (($state === 'check_failed') ? 'danger' : 'success');
        nv_toast_set($type, (string)($status['message'] ?? 'Update check done.'));
        header('Location: ./index.php');
        exit;
    }

    if ($do === 'apply_update') {
        [$ok, $output] = connector_run_update_script();
        $status = connector_check_update_status();
        $shortOutput = connector_one_line($output);

        if ($ok) {
            if (stripos($output, 'No update available') !== false) {
                nv_toast_set('info', $shortOutput !== '' ? $shortOutput : 'No update available.');
            } else {
                nv_toast_set('success', $shortOutput !== '' ? $shortOutput : 'Update script executed successfully.');
            }
            $status['last_update'] = 'ok';
        } else {
            $msg = 'Update failed: ' . ($shortOutput !== '' ? $shortOutput : 'unknown_error');
            nv_toast_set('danger', $msg);
            $status['last_update'] = 'failed';
            $status['last_error'] = $shortOutput;
        }
        $_SESSION['connector_update_status'] = $status;
        header('Location: ./index.php');
        exit;
    }

    if ($id > 0 && in_array($do, ['start','stop','delete','start_all','stop_all','start_tracker'], true)) {

        if ($do === 'start' || $do === 'stop') {
            if (!in_array($svc, ['runner','tracker'], true)) {
                $_SESSION['inst_toast'] = ['type'=>'danger', 'msg'=>"Invalid service for instance #{$id}"];
                header('Location: ./index.php');
                exit;
            }

            $unit = 'nuxvision-' . $svc . '@' . $id . '.service';
            [$ok, , $out] = sudo_systemctl($do, $unit);

            $_SESSION['inst_toast'] = $ok
                ? ['type'=>'success', 'msg'=> strtoupper($do) . " OK for {$svc} instance #{$id}"]
                : ['type'=>'danger', 'msg'=> strtoupper($do) . " failed for {$svc} instance #{$id}: " . ($out ?: 'error')];

            header('Location: ./index.php');
            exit;
        }

        if ($do === 'start_all' || $do === 'stop_all') {
            $cmd = ($do === 'start_all') ? 'start' : 'stop';
            $units = [
                'nuxvision-runner@' . $id . '.service',
                'nuxvision-tracker@' . $id . '.service',
            ];

            $failed = [];
            foreach ($units as $u) {
                [$ok, , $out] = sudo_systemctl($cmd, $u);
                if (!$ok) $failed[] = $u . ($out !== '' ? ' (' . $out . ')' : '');
            }

            if (!$failed) {
                $_SESSION['inst_toast'] = ['type'=>'success', 'msg'=> strtoupper($cmd) . " OK for instance #{$id} (runner + tracker)"];
            } else {
                $_SESSION['inst_toast'] = ['type'=>'danger', 'msg'=> strtoupper($cmd) . " failed on instance #{$id}: " . implode(' | ', $failed)];
            }

            header('Location: ./index.php');
            exit;
        }

        if ($do === 'start_tracker') {
            $unit = 'nuxvision-tracker@' . $id . '.service';
            [$ok, , $out] = sudo_systemctl('start', $unit);
            $_SESSION['inst_toast'] = $ok
                ? ['type'=>'success', 'msg'=> "START OK for tracker instance #{$id}"]
                : ['type'=>'danger', 'msg'=> "START failed for tracker instance #{$id}: " . ($out ?: 'error')];
            header('Location: ./index.php');
            exit;
        }

        if ($do === 'delete') {
            $runnerUnit  = 'nuxvision-runner@' . $id . '.service';
            $trackerUnit = 'nuxvision-tracker@' . $id . '.service';

            [, , $outR] = sudo_systemctl('is-active', $runnerUnit);
            [, , $outT] = sudo_systemctl('is-active', $trackerUnit);

            if (trim($outR) === 'active' || trim($outT) === 'active') {
                $_SESSION['inst_toast'] = ['type'=>'warning', 'msg'=>"Stop runner/tracker for instance #{$id} before deleting."];
                header('Location: ./index.php');
                exit;
            }

            sudo_systemctl('stop', $runnerUnit);
            sudo_systemctl('stop', $trackerUnit);

            $del = nv_delete_instance_dir($id);

            $_SESSION['inst_toast'] = !empty($del['ok'])
                ? ['type'=>'success', 'msg'=>"Instance #{$id} deleted"]
                : ['type'=>'danger', 'msg'=>"Delete failed for instance #{$id}: " . (string)($del['error'] ?? 'error')];

            header('Location: ./index.php');
            exit;
        }
    }
}

// HOME = dashboard => no stepper
render_header('NuxVision Connector', 'Home', false, true);
?>

<style>
  .home-toolbar {
    border: 1px solid rgba(150,176,250,.25);
    border-radius: 14px;
    background: linear-gradient(145deg, rgba(16,25,49,.8), rgba(12,20,37,.82));
    padding: .65rem;
  }
  .update-banner{
    border:1px solid rgba(136,204,255,.52);
    border-radius:12px;
    background:
      radial-gradient(1100px 120px at 12% 0%, rgba(173,231,255,.2), transparent 58%),
      linear-gradient(135deg, rgba(18,66,92,.92), rgba(10,37,70,.92));
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,.22),
      0 10px 28px rgba(6,18,40,.28);
    color:#eaf6ff;
  }
  .update-banner .banner-main{
    display:flex;
    align-items:center;
    gap:.55rem;
    min-width:0;
    white-space:nowrap;
    overflow:hidden;
    text-overflow:ellipsis;
  }
  .update-banner .pulse{
    width:.55rem;
    height:.55rem;
    border-radius:999px;
    background:#2ea8ff;
    box-shadow:0 0 0 5px rgba(46,168,255,.18);
    flex:0 0 auto;
  }
  .update-banner .label{
    font-weight:900;
    color:#ffffff;
    text-shadow:0 1px 0 rgba(0,0,0,.25);
  }
  .update-banner .meta{
    display:inline-flex;
    align-items:center;
    gap:.35rem;
    color:#e1f3ff;
    font-weight:700;
    text-shadow:0 1px 0 rgba(0,0,0,.2);
    min-width:0;
    white-space:nowrap;
    overflow:hidden;
    text-overflow:ellipsis;
  }
  .update-banner .chip{
    display:inline-flex;
    align-items:center;
    border:1px solid rgba(164,217,255,.45);
    border-radius:999px;
    padding:.1rem .45rem;
    background:rgba(10,58,96,.58);
    color:#f3fbff;
    font-weight:700;
    font-size:.9rem;
  }
  .home-toolbar .form-control { min-height: 46px; }
  .home-actions { display:flex; gap:.6rem; }
  .home-actions .btn{
    display:inline-flex;
    align-items:center;
    justify-content:center;
    text-align:center;
    min-height:46px;
  }
  .status-filters {
    display:flex;
    flex-wrap: wrap;
    border:1px solid rgba(150,176,250,.25);
    border-radius:12px;
    overflow:hidden;
    background:rgba(8,14,30,.55);
  }
  .status-filters .btn {
    border:none;
    border-right:1px solid rgba(150,176,250,.2);
    border-radius:0;
    color:#d7e4ff;
    min-height:46px;
    padding:.35rem .95rem;
    background:transparent;
  }
  .status-filters .btn:last-child { border-right: none; }
  .status-filters .btn.active,
  .status-filters .btn:hover { background: rgba(50,91,190,.32); color:#fff; }
  .status-dot {
    width:.62rem;height:.62rem;border-radius:999px;display:inline-block;margin-right:.5rem;
    box-shadow:0 0 0 1px rgba(255,255,255,.25) inset;
  }
  .dot-running { background:#22b07d; }
  .dot-stopped { background:#7280a4; }
  .dot-error { background:#cf4d62; }
  .dot-all { background:#2f7bff; }
  .summary-grid {
    display:grid;
    gap:1rem;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    margin-bottom:1rem;
  }
  .summary-card {
    border:1px solid rgba(150,176,250,.24);
    border-radius:14px;
    padding:.95rem 1rem;
    background:linear-gradient(130deg, rgba(17,28,54,.9), rgba(12,20,38,.94));
  }
  .summary-card.running { background:linear-gradient(135deg, rgba(27,87,78,.56), rgba(16,35,56,.95)); }
  .summary-card.stopped { background:linear-gradient(135deg, rgba(53,66,96,.55), rgba(16,29,58,.95)); }
  .summary-card.error { background:linear-gradient(135deg, rgba(111,46,68,.6), rgba(30,24,51,.96)); }
  .summary-num { font-size:2rem; font-weight:800; line-height:1; }
  .total-chip {
    border:1px solid rgba(150,176,250,.2);
    border-radius:10px;
    background:rgba(8,14,29,.62);
    padding:.52rem .7rem;
    display:flex;
    justify-content:space-between;
    gap:.75rem;
    min-height:46px;
    align-items:center;
  }
  .side-card { padding:1rem; }
  .side-title {
    font-size:1.5rem;
    font-weight:700;
    margin:0 0 .75rem 0;
  }
  .side-head{
    display:flex;
    align-items:center;
    justify-content:space-between;
    gap:.75rem;
    margin-bottom:.75rem;
  }
  .view-toggle{
    display:inline-flex;
    border:1px solid rgba(150,176,250,.24);
    border-radius:10px;
    overflow:hidden;
    background:rgba(10,16,34,.6);
  }
  .view-toggle .btn{
    border:0;
    border-radius:0;
    min-height:34px;
    padding:.28rem .65rem;
    color:#d5e2ff;
    background:transparent;
    font-size:.88rem;
  }
  .view-toggle .btn.active{
    background:rgba(50,91,190,.38);
    color:#fff;
  }
  .activity-list {
    list-style:none;
    margin:0;
    padding:0;
    display:grid;
    gap:.42rem;
  }
  .activity-item {
    border:1px solid rgba(150,176,250,.18);
    border-radius:10px;
    padding:.52rem .62rem;
    background:rgba(8,14,29,.56);
  }
  .activity-head {
    display:flex;
    align-items:center;
    justify-content:space-between;
    gap:.5rem;
    margin-bottom:.22rem;
    color:#b6c7ea;
    font-size:.88rem;
  }
  .activity-msg {
    color:#e9efff;
    font-size:.93rem;
    line-height:1.25;
    white-space:normal;
    overflow:hidden;
    display:-webkit-box;
    -webkit-line-clamp:2;
    -webkit-box-orient:vertical;
  }
  .activity-msg.raw{
    font-size:.86rem;
    color:#b9c6e4;
    white-space:nowrap;
    overflow:hidden;
    text-overflow:ellipsis;
    display:block;
  }
  .lvl{
    display:inline-flex;
    align-items:center;
    border:1px solid rgba(150,176,250,.25);
    border-radius:999px;
    font-size:.72rem;
    line-height:1;
    padding:.22rem .5rem;
    font-weight:700;
  }
  .lvl.warn{ color:#ffd89b; border-color:rgba(222,169,83,.55); background:rgba(137,93,21,.22);}
  .lvl.error{ color:#ffb6c2; border-color:rgba(203,93,115,.55); background:rgba(113,26,46,.28);}
  .lvl.info{ color:#b9d8ff; border-color:rgba(88,140,233,.55); background:rgba(29,60,119,.28);}
  .lvl.debug,.lvl.trace{ color:#b8c1dd; border-color:rgba(120,133,170,.45); background:rgba(45,55,83,.28);}
  .activity-meta{
    display:flex;
    align-items:center;
    gap:.45rem;
    flex-wrap:wrap;
  }
  .instance-grid {
    display:grid;
    gap:1rem;
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .instance-card {
    border:1px solid rgba(150,176,250,.24);
    border-radius:14px;
    background:linear-gradient(130deg, rgba(17,28,54,.9), rgba(12,20,38,.94));
    padding:1rem;
  }
  .instance-head {
    display:flex;
    justify-content:space-between;
    align-items:flex-start;
    gap:1rem;
  }
  .status-badge {
    display:inline-flex;
    align-items:center;
    padding:.24rem .68rem;
    border-radius:11px;
    font-size:.95rem;
    border:1px solid;
  }
  .status-running { background:rgba(39,153,111,.2); border-color:rgba(79,197,154,.45); color:#c8ffe9; }
  .status-stopped { background:rgba(115,124,151,.26); border-color:rgba(153,163,196,.38); color:#e1e7ff; }
  .status-degraded { background:rgba(177,138,79,.26); border-color:rgba(213,175,109,.45); color:#ffe7c0; }
  .status-error { background:rgba(178,66,87,.24); border-color:rgba(208,90,113,.45); color:#ffd6df; }
  .path-line {
    margin-top:.45rem;
    padding:0;
    border:none;
    background:transparent;
    display:flex;
    align-items:center;
    gap:.45rem;
    flex-wrap:wrap;
    font-size:.94rem;
    color:#b8c6e8;
  }
  .svc-row {
    margin-top:.8rem;
    border-top:1px solid rgba(150,176,250,.18);
    padding-top:.8rem;
    display:flex;
    flex-wrap:nowrap;
    gap:.7rem;
    overflow-x:auto;
    overflow-y:hidden;
  }
  .svc-pill { color:#d8e4ff; display:inline-flex; align-items:center; gap:.45rem; white-space:nowrap; flex:0 0 auto; }
  .svc-pill .dot { width:.64rem; height:.64rem; border-radius:999px; }
  .svc-pill .dot.on { background:#22b07d; }
  .svc-pill .dot.off { background:#677495; }
  .svc-pill .dot.err { background:#cf4d62; }
  .card-right {
    display:flex;
    align-items:flex-start;
    gap:.5rem;
  }
  .btn-main-action {
    min-width:120px;
    font-weight:700;
    min-height:44px;
    height:44px;
  }
  .btn-startall {
    background:linear-gradient(180deg, #2f7bff, #1d56cb);
    border:1px solid rgba(115,160,255,.45);
    color:#fff;
  }
  .btn-stopall {
    background:linear-gradient(180deg, #c35d42, #8f3320);
    border:1px solid rgba(214,127,102,.46);
    color:#fff;
  }
  .action-menu { position:relative; }
  .action-menu > summary {
    width:44px;
    height:44px;
    border:1px solid rgba(150,176,250,.3);
    border-radius:10px;
    display:flex;
    align-items:center;
    justify-content:center;
    cursor:pointer;
    background:rgba(10,16,34,.75);
  }
  .action-pop {
    position:absolute;
    right:0;
    top:50px;
    min-width:180px;
    border:1px solid rgba(150,176,250,.36);
    border-radius:12px;
    background:linear-gradient(140deg, rgba(13,20,37,.98), rgba(16,24,45,.98));
    z-index:4;
    padding:.4rem;
    box-shadow:0 18px 34px rgba(0,0,0,.5);
  }
  .action-pop a,
  .action-pop button {
    width:100%;
    text-align:left;
    border:0;
    background:transparent;
    color:#e8efff;
    padding:.55rem .6rem;
    border-radius:8px;
    text-decoration:none;
  }
  .action-pop a:hover,
  .action-pop button:hover { background:rgba(56,87,158,.35); }
  .action-pop .danger { color:#ff9daf; }
  .empty-state {
    border:1px dashed rgba(150,176,250,.35);
    border-radius:15px;
    padding:1.2rem;
    background:rgba(12,19,37,.52);
  }
  @media (max-width: 992px) {
    .summary-grid { grid-template-columns: 1fr; }
    .instance-grid { grid-template-columns: 1fr; }
    .instance-head { flex-direction:column; }
    .card-right { width:100%; justify-content:flex-end; }
    .svc-row { flex-wrap:wrap; overflow:visible; }
    .update-banner .banner-main{
      white-space:normal;
      overflow:visible;
      text-overflow:clip;
    }
    .update-banner .meta{
      white-space:normal;
      overflow:visible;
      text-overflow:clip;
      flex-wrap:wrap;
    }
  }
</style>

<?php
$instances = list_local_instances($INSTANCES_DIR);

// enrich instances: service status (runner + tracker)
foreach ($instances as &$it) {
    $id = (int)$it['id'];

    $runnerUnit  = 'nuxvision-runner@' . $id . '.service';
    $trackerUnit = 'nuxvision-tracker@' . $id . '.service';

    [$runnerState, $runnerRunning] = service_state_for_unit($runnerUnit);
    [$trkState,    $trkRunning]    = service_state_for_unit($trackerUnit);

    $it['any_running'] = ($runnerRunning || $trkRunning);

    $it['services'] = [
        [
            'key' => 'runner',
            'name' => $runnerUnit,
            'state' => $runnerState,
            'running' => $runnerRunning,
        ],
        [
            'key' => 'tracker',
            'name' => $trackerUnit,
            'state' => $trkState,
            'running' => $trkRunning,
        ],
    ];

    $runnerPid = $runnerRunning ? unit_main_pid($runnerUnit) : 0;
    $trackerPid = $trkRunning ? unit_main_pid($trackerUnit) : 0;
    $runnerUsage = pid_usage_snapshot($runnerPid);
    $trackerUsage = pid_usage_snapshot($trackerPid);

    $cpuTotal = (float)$runnerUsage['cpu_pct'] + (float)$trackerUsage['cpu_pct'];
    $ramKbTotal = (int)$runnerUsage['rss_kb'] + (int)$trackerUsage['rss_kb'];
    $it['metrics'] = [
        'cpu_pct' => number_format($cpuTotal, 1, '.', ''),
        'ram_mb' => kb_to_mb_str($ramKbTotal),
    ];
}
unset($it);

$totalInstances = count($instances);
$runningCount = 0;
$stoppedCount = 0;
$errorCount = 0;
$runnerActiveCount = 0;
$trackerActiveCount = 0;
$totalCpu = 0.0;
$totalRamMb = 0.0;

foreach ($instances as $it) {
    $services = is_array($it['services'] ?? null) ? $it['services'] : [];
    $status = classify_instance_status($services);
    if (($status['key'] ?? '') === 'running') $runningCount++;
    elseif (($status['key'] ?? '') === 'stopped') $stoppedCount++;
    else $errorCount++;

    if (!empty($services[0]['running'])) $runnerActiveCount++;
    if (!empty($services[1]['running'])) $trackerActiveCount++;

    $totalCpu += (float)($it['metrics']['cpu_pct'] ?? 0.0);
    $totalRamMb += (float)($it['metrics']['ram_mb'] ?? 0.0);
}

$recentActivity = nv_collect_recent_activity($instances, 12);
$hasInstances = !empty($instances);

$updateStatus = $_SESSION['connector_update_status'] ?? [];
if (!is_array($updateStatus)) $updateStatus = [];
if (!isset($updateStatus['local_version']) || trim((string)$updateStatus['local_version']) === '') {
    $updateStatus['local_version'] = connector_local_version();
}
$updateState = (string)($updateStatus['state'] ?? 'idle');
$updateLocal = trim((string)($updateStatus['local_version'] ?? '0'));
$updateRemote = trim((string)($updateStatus['remote_version'] ?? ''));
$updateMessage = trim((string)($updateStatus['message'] ?? 'Check if an update is available.'));
$updateCheckedAt = trim((string)($updateStatus['checked_at'] ?? ''));
$canApplyUpdate = ($updateState === 'update_available');
$showUpdateAlert = $canApplyUpdate;
?>

<div class="d-flex flex-wrap justify-content-end gap-2 mb-3">
  <div class="home-actions">
    <a class="btn btn-soft" href="./index.php"><i class="bi bi-house me-1"></i>Home</a>
    <a class="btn btn-soft" href="./index.php"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</a>
    <?php if ($hasInstances): ?>
    <a class="btn btn-accent" href="./nuxvision.php"><i class="bi bi-plus-lg me-1"></i>New Instance</a>
    <?php endif; ?>
  </div>
</div>

<script>
  (function () {
    const currentState = <?=json_encode($updateState, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)?>;
    const c = new AbortController();
    const t = setTimeout(() => c.abort(), 1200);

    fetch('./index.php', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        'X-Requested-With': 'XMLHttpRequest'
      },
      body: 'do=check_update_silent',
      credentials: 'same-origin',
      signal: c.signal
    })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => {
        clearTimeout(t);
        if (!j || !j.ok) return;
        if (currentState !== 'update_available' && j.state === 'update_available') {
          window.location.reload();
        }
      })
      .catch(() => {});
  })();
</script>

<?php if ($showUpdateAlert): ?>
<div class="alert alert-info update-banner py-2 px-3 d-flex align-items-center justify-content-between flex-nowrap gap-2 mb-3" role="alert">
  <div class="banner-main">
    <span class="pulse" aria-hidden="true"></span>
    <span class="label">Update available</span>
    <span class="meta">
      <span class="chip">Local: <span class="mono ms-1"><?=h($updateLocal !== '' ? $updateLocal : '0')?></span></span>
      <span class="chip">Remote: <span class="mono ms-1"><?=h($updateRemote)?></span></span>
      <?php if ($updateCheckedAt !== ''): ?><span class="chip">Checked: <?=h($updateCheckedAt)?></span><?php endif; ?>
    </span>
  </div>
  <form method="post" class="m-0">
    <input type="hidden" name="do" value="apply_update">
    <button type="submit" class="btn btn-accent" onclick="return confirm('Run connector update now?');">
      <i class="bi bi-arrow-up-circle me-1"></i>Update now
    </button>
  </form>
</div>
<?php endif; ?>

<?php if (!$instances): ?>
  <div class="nv-card p-4">
    <div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
      <div>
        <div class="h5 fw-bold mb-1">Add a new instance</div>
        <div class="nv-muted">Run the wizard to generate <span class="mono">instances/&lt;id&gt;/config.php</span>.</div>
      </div>
      <a class="btn btn-accent px-4" href="./nuxvision.php"><i class="bi bi-plus-circle me-1"></i>Start setup</a>
    </div>
  </div>

<?php else: ?>
  <a id="instances"></a>

  <div class="summary-grid">
    <div class="summary-card running">
      <div class="summary-num"><?=h((string)$runningCount)?></div>
      <div class="nv-muted">Running instances</div>
    </div>
    <div class="summary-card stopped">
      <div class="summary-num"><?=h((string)$stoppedCount)?></div>
      <div class="nv-muted">Stopped instances</div>
    </div>
    <div class="summary-card error">
      <div class="summary-num"><?=h((string)$errorCount)?></div>
      <div class="nv-muted">Error / degraded instances</div>
    </div>
  </div>

  <div class="home-toolbar mb-3">
        <div class="row g-2 align-items-stretch">
          <div class="col-lg-5">
            <div class="input-group">
              <span class="input-group-text btn-soft border-0"><i class="bi bi-search"></i></span>
              <input id="instanceSearch" class="form-control border-0" type="text" placeholder="Search instances...">
            </div>
          </div>
          <div class="col-lg-3">
            <div class="d-flex gap-2">
              <div class="total-chip flex-fill"><span>Total CPU</span><strong><?=h(number_format($totalCpu, 1, '.', ''))?>%</strong></div>
              <div class="total-chip flex-fill"><span>Total RAM</span><strong><?=h(number_format($totalRamMb, 1, '.', ''))?> MB</strong></div>
            </div>
          </div>
          <div class="col-lg-4">
            <div class="d-flex flex-wrap gap-2 justify-content-lg-end h-100 align-items-center">
              <div class="status-filters" role="group" aria-label="Status filters">
                <button type="button" class="btn active" data-filter="all"><span class="status-dot dot-all"></span>All</button>
                <button type="button" class="btn" data-filter="running"><span class="status-dot dot-running"></span>Running</button>
                <button type="button" class="btn" data-filter="stopped"><span class="status-dot dot-stopped"></span>Stopped</button>
                <button type="button" class="btn" data-filter="error"><span class="status-dot dot-error"></span>Error</button>
              </div>
            </div>
          </div>
        </div>
  </div>

  <div class="instance-grid" id="instanceGrid">
        <?php foreach ($instances as $it):
          $id = (int)$it['id'];
          $services = is_array($it['services'] ?? null) ? $it['services'] : [];
          $status = classify_instance_status($services);
          $anyRunning = !empty($it['any_running']);

          $runnerState = (string)($services[0]['state'] ?? 'unknown');
          $trackerState = (string)($services[1]['state'] ?? 'unknown');
          $runnerRunning = !empty($services[0]['running']);
          $trackerRunning = !empty($services[1]['running']);
          $cpuPct = (string)($it['metrics']['cpu_pct'] ?? '0.0');
          $ramMb = (string)($it['metrics']['ram_mb'] ?? '0.0');
          $metricDot = $anyRunning ? 'on' : 'off';
          $filterStatus = (($status['key'] ?? '') === 'degraded') ? 'error' : (string)($status['key'] ?? 'all');
          $canDelete = !$anyRunning;
          $cfgPath = (string)($it['cfg_path'] ?? '');
          $searchBlob = strtolower('instance #' . $id . ' ' . $cfgPath . ' ' . $status['label'] . ' ' . $runnerState . ' ' . $trackerState . ' ' . $cpuPct . ' ' . $ramMb);
        ?>
          <article class="instance-card" data-status="<?=h($filterStatus)?>" data-search="<?=h($searchBlob)?>">
            <div class="instance-head">
              <div>
                <div class="d-flex align-items-center gap-2 flex-wrap mb-1">
                  <div class="fw-bold" style="font-size:2rem; line-height:1.05;">Instance #<?=h((string)$id)?></div>
                  <span class="status-badge <?=h((string)$status['badge'])?>"><?=h((string)$status['label'])?></span>
                </div>

                <div class="path-line">
                  <span class="nv-muted">Config:</span>
                  <span class="mono"><?=h($cfgPath)?></span>
                </div>

                <div class="svc-row">
                  <div class="svc-pill">
                    <?php $dotRunner = $runnerRunning ? 'on' : ((in_array($runnerState, ['inactive'], true)) ? 'off' : 'err'); ?>
                    <span class="dot <?=h($dotRunner)?>"></span>
                    <span>Runner: <strong><?=h($runnerState)?></strong></span>
                  </div>
                  <div class="svc-pill">
                    <?php $dotTracker = $trackerRunning ? 'on' : ((in_array($trackerState, ['inactive'], true)) ? 'off' : 'err'); ?>
                    <span class="dot <?=h($dotTracker)?>"></span>
                    <span>Tracker: <strong><?=h($trackerState)?></strong></span>
                  </div>
                  <div class="svc-pill">
                    <span class="dot <?=h($metricDot)?>"></span>
                    <span>CPU: <strong><?=h($cpuPct)?>%</strong> · RAM: <strong><?=h($ramMb)?> MB</strong></span>
                  </div>
                </div>
              </div>

              <div class="card-right">
                <form method="post" class="m-0">
                  <input type="hidden" name="instance_id" value="<?=h((string)$id)?>">
                  <input type="hidden" name="do" value="<?=h($anyRunning ? 'stop_all' : 'start_all')?>">
                  <button class="btn btn-main-action <?=h($anyRunning ? 'btn-stopall' : 'btn-startall')?>" type="submit"
                          onclick="return confirm('<?=h($anyRunning ? 'Stop runner and tracker' : 'Start runner and tracker')?> for instance #<?=h((string)$id)?>?');">
                    <?= $anyRunning ? 'Stop' : 'Start' ?>
                  </button>
                </form>

                <details class="action-menu">
                  <summary><i class="bi bi-three-dots"></i></summary>
                  <div class="action-pop">
                    <form method="post" class="m-0">
                      <input type="hidden" name="instance_id" value="<?=h((string)$id)?>">
                      <input type="hidden" name="do" value="start_tracker">
                      <button type="submit"><i class="bi bi-play-circle me-1"></i>Start tracker only</button>
                    </form>
                    <a href="./nuxvision.php?instance_id=<?=h((string)$id)?>"><i class="bi bi-wrench-adjustable-circle me-1"></i>Setup</a>
                    <a href="./logs.php?instance_id=<?=h((string)$id)?>"><i class="bi bi-journal-text me-1"></i>Logs</a>
                    <a href="./diagnostics.php?instance_id=<?=h((string)$id)?>"><i class="bi bi-activity me-1"></i>Diagnostics</a>
                    <hr class="my-1" style="border-color:rgba(150,176,250,.22)">

                    <?php if (!$canDelete): ?>
                      <button type="button" class="danger" disabled title="Stop services before deleting.">
                        <i class="bi bi-trash3 me-1"></i>Delete
                      </button>
                    <?php else: ?>
                      <form method="post" class="m-0">
                        <input type="hidden" name="instance_id" value="<?=h((string)$id)?>">
                        <input type="hidden" name="do" value="delete">
                        <button class="danger" type="submit"
                                onclick="return confirm('Delete instance #<?=h((string)$id)?> and local files in instances/<?=h((string)$id)?> ?');">
                          <i class="bi bi-trash3 me-1"></i>Delete
                        </button>
                      </form>
                    <?php endif; ?>
                  </div>
                </details>
              </div>
            </div>
          </article>
        <?php endforeach; ?>
      </div>
  <div class="nv-card side-card mt-3">
    <div class="side-head">
      <h3 class="side-title mb-0">Recent Activity</h3>
      <div class="view-toggle" role="group" aria-label="Activity view">
        <button type="button" class="btn active" data-activity-view="clean">Clean</button>
        <button type="button" class="btn" data-activity-view="raw">Raw</button>
      </div>
    </div>
    <?php if (!$recentActivity): ?>
      <div class="smallhint">No recent logs found. Click Refresh after services run.</div>
    <?php else: ?>
      <ul class="activity-list">
        <?php foreach ($recentActivity as $ev): ?>
          <li class="activity-item">
            <div class="activity-head">
              <span class="activity-meta">
                <span><?=h((string)$ev['time'])?></span>
                <span>· #<?=h((string)$ev['instance_id'])?> · <?=h((string)$ev['service'])?></span>
                <span class="lvl <?=h(strtolower((string)($ev['level'] ?? 'info')))?>"><?=h((string)($ev['level'] ?? 'INFO'))?></span>
              </span>
            </div>
            <div class="activity-msg" data-activity-clean><?=h((string)($ev['summary'] ?? $ev['msg']))?></div>
            <div class="activity-msg raw d-none" data-activity-raw><?=h((string)$ev['msg'])?></div>
          </li>
        <?php endforeach; ?>
      </ul>
      <div class="smallhint mt-2">Sorted by latest log time. Manual refresh only.</div>
    <?php endif; ?>
  </div>

  <script>
    (function () {
      const searchEl = document.getElementById('instanceSearch');
      const cards = Array.from(document.querySelectorAll('#instanceGrid .instance-card'));
      const filters = Array.from(document.querySelectorAll('[data-filter]'));
      let activeFilter = 'all';

      function refreshList() {
        const q = (searchEl && searchEl.value ? searchEl.value : '').toLowerCase().trim();
        cards.forEach((card) => {
          const status = (card.getAttribute('data-status') || '').toLowerCase();
          const text = (card.getAttribute('data-search') || '').toLowerCase();
          const okFilter = (activeFilter === 'all') || (status === activeFilter);
          const okSearch = !q || text.includes(q);
          card.style.display = okFilter && okSearch ? '' : 'none';
        });
      }

      filters.forEach((btn) => {
        btn.addEventListener('click', function () {
          activeFilter = (btn.getAttribute('data-filter') || 'all').toLowerCase();
          filters.forEach((b) => b.classList.remove('active'));
          btn.classList.add('active');
          refreshList();
        });
      });

      if (searchEl) {
        searchEl.addEventListener('input', refreshList);
      }

      const viewBtns = Array.from(document.querySelectorAll('[data-activity-view]'));
      const cleanLines = Array.from(document.querySelectorAll('[data-activity-clean]'));
      const rawLines = Array.from(document.querySelectorAll('[data-activity-raw]'));

      function setActivityView(mode) {
        const raw = mode === 'raw';
        viewBtns.forEach((btn) => {
          btn.classList.toggle('active', (btn.getAttribute('data-activity-view') || '') === mode);
        });
        cleanLines.forEach((el) => el.classList.toggle('d-none', raw));
        rawLines.forEach((el) => el.classList.toggle('d-none', !raw));
      }

      viewBtns.forEach((btn) => {
        btn.addEventListener('click', () => {
          setActivityView((btn.getAttribute('data-activity-view') || 'clean').toLowerCase());
        });
      });

      refreshList();
      setActivityView('clean');
    })();
  </script>
<?php endif; ?>

<?php
render_footer();