mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-07-25 17:42:57 +00:00
259 lines
8.1 KiB
HTML
259 lines
8.1 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Tailscale VPN</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
|
background: #f5f6f8;
|
|
color: #1a1a2e;
|
|
padding: 20px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
header svg { flex-shrink: 0; }
|
|
|
|
header h1 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #1a1a2e;
|
|
}
|
|
|
|
.card {
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
padding: 18px 20px;
|
|
margin-bottom: 14px;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
|
}
|
|
|
|
/* ── Status row ── */
|
|
.status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.dot {
|
|
width: 11px;
|
|
height: 11px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dot.connected { background: #22c55e; box-shadow: 0 0 0 3px rgba(34,197,94,.2); }
|
|
.dot.connecting { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,.2); }
|
|
.dot.disconnected { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.2); }
|
|
|
|
.status-label {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ── Auth URL block ── */
|
|
#auth-block {
|
|
background: #fffbeb;
|
|
border: 1px solid #fde68a;
|
|
border-radius: 8px;
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
#auth-block p {
|
|
font-size: 12px;
|
|
color: #92400e;
|
|
margin-bottom: 10px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
#auth-link {
|
|
display: inline-block;
|
|
background: #0166ff;
|
|
color: #fff;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
#auth-link:hover { background: #0052cc; }
|
|
|
|
#auth-url-text {
|
|
display: block;
|
|
font-size: 11px;
|
|
color: #888;
|
|
word-break: break-all;
|
|
font-family: monospace;
|
|
}
|
|
|
|
/* ── Info grid (connected state) ── */
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 6px 16px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.info-grid .label { color: #888; }
|
|
.info-grid .value { font-weight: 500; font-family: monospace; }
|
|
|
|
/* ── Log section ── */
|
|
.log-header {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.6px;
|
|
color: #999;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
#log-frame {
|
|
width: 100%;
|
|
height: 500px;
|
|
border: 1px solid #e8e8ec;
|
|
border-radius: 6px;
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<!-- Tailscale wordmark icon -->
|
|
<svg width="28" height="28" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
|
<circle cx="64" cy="64" r="18" fill="white"/>
|
|
<circle cx="64" cy="22" r="10" fill="white"/>
|
|
<circle cx="64" cy="106" r="10" fill="white"/>
|
|
<circle cx="22" cy="64" r="10" fill="white"/>
|
|
<circle cx="106" cy="64" r="10" fill="white"/>
|
|
<circle cx="35" cy="35" r="10" fill="white"/>
|
|
<circle cx="93" cy="35" r="10" fill="white"/>
|
|
<circle cx="35" cy="93" r="10" fill="white"/>
|
|
<circle cx="93" cy="93" r="10" fill="white"/>
|
|
</svg>
|
|
<h1>Tailscale VPN</h1>
|
|
</header>
|
|
|
|
<!-- Status card -->
|
|
<div class="card">
|
|
<div class="status-row">
|
|
<span id="dot" class="dot connecting"></span>
|
|
<span id="status-label" class="status-label">Checking…</span>
|
|
</div>
|
|
|
|
<!-- Shown when waiting for auth -->
|
|
<div id="auth-block" style="display:none;">
|
|
<p>Open this link in a browser to authenticate this device:</p>
|
|
<a id="auth-link" href="#" target="_blank">Authenticate →</a>
|
|
<span id="auth-url-text"></span>
|
|
</div>
|
|
|
|
<!-- Shown when connected -->
|
|
<div id="info-block" style="display:none;" class="info-grid">
|
|
<span class="label">Tailscale IP</span><span class="value" id="ts-ip">—</span>
|
|
<span class="label">Node</span><span class="value" id="ts-node">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Log card -->
|
|
<div class="card">
|
|
<div class="log-header">Service Log</div>
|
|
<iframe id="log-frame" src="/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN"></iframe>
|
|
</div>
|
|
|
|
<script>
|
|
var LOG = '/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN';
|
|
|
|
function parse(txt) {
|
|
// Use the LAST occurrence of each pattern so stale log entries don't win
|
|
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
|
|
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
|
|
|
|
// Tailscale IP — 100.x.x.x range
|
|
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
|
|
var tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
|
|
|
// Node name from "Logged into tailnet" line or "acap-" username pattern
|
|
var nodeMatch = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/);
|
|
var node = nodeMatch ? nodeMatch[1] : null;
|
|
|
|
// Determine state from the LAST state-transition log line
|
|
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
|
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
|
var isRunning = /-> Running/.test(lastState) || /Logged into tailnet/.test(txt);
|
|
|
|
if (isRunning) {
|
|
return { state: 'connected', url: null, ip: tsIP, node: node };
|
|
}
|
|
if (latestUrl) {
|
|
return { state: 'connecting', url: latestUrl, ip: null, node: null };
|
|
}
|
|
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) {
|
|
return { state: 'connecting', url: null, ip: null, node: null };
|
|
}
|
|
return { state: 'disconnected', url: null, ip: null, node: null };
|
|
}
|
|
|
|
function render(r) {
|
|
var dot = document.getElementById('dot');
|
|
var label = document.getElementById('status-label');
|
|
var auth = document.getElementById('auth-block');
|
|
var info = document.getElementById('info-block');
|
|
|
|
dot.className = 'dot ' + r.state;
|
|
|
|
var labels = { connected: 'Connected', connecting: 'Connecting\u2026', disconnected: 'Stopped' };
|
|
label.textContent = labels[r.state];
|
|
|
|
// Auth block
|
|
if (r.state === 'connecting' && r.url) {
|
|
document.getElementById('auth-link').href = r.url;
|
|
document.getElementById('auth-url-text').textContent = r.url;
|
|
auth.style.display = '';
|
|
} else {
|
|
auth.style.display = 'none';
|
|
}
|
|
|
|
// Info block
|
|
if (r.state === 'connected') {
|
|
document.getElementById('ts-ip').textContent = r.ip || '\u2014';
|
|
document.getElementById('ts-node').textContent = r.node || '\u2014';
|
|
info.style.display = '';
|
|
} else {
|
|
info.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function refresh() {
|
|
// Reload the log iframe (same-origin)
|
|
var frame = document.getElementById('log-frame');
|
|
try { frame.contentWindow.location.reload(); } catch(e) {}
|
|
|
|
fetch(LOG, { cache: 'no-store', credentials: 'same-origin' })
|
|
.then(function(r) { return r.text(); })
|
|
.then(function(txt) { render(parse(txt)); })
|
|
.catch(function() {
|
|
document.getElementById('status-label').textContent = 'Unknown';
|
|
});
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 5000);
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|