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/<id>/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();