mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-10-19 07:33:16 +00:00
Merge pull request #35 from ChrispyBacon-dev/unstable
Some checks failed
Docker Image Build and Push (Conditional Arch) / build (push) Has been cancelled
Some checks failed
Docker Image Build and Push (Conditional Arch) / build (push) Has been cancelled
Centralized Cloudflare Tunnel Visibility & DNS Inspection Across Multiple Hosts
This commit is contained in:
commit
f14ccd91cc
4 changed files with 369 additions and 356 deletions
|
|
@ -29,6 +29,10 @@ DockFlare automates Cloudflare Tunnel ingress rule management based on Docker co
|
|||
- Tunnel & agent status.
|
||||
- Start/Stop agent controls.
|
||||
- Managed ingress rule list with status, container ID, deletion time, and "Force Delete" option.
|
||||
- **List of all Cloudflare Tunnels on Account (NEW)**: Shows all tunnels on the configured Cloudflare account (not just the one managed by this DockFlare instance), including their name, ID, status, creation date, and active connections. Users can click a '+' icon next to each tunnel to dynamically load and view its associated CNAME DNS records found within the configured zones.
|
||||
- Real-time activity logs streamed directly to the browser.
|
||||
- Dark/Light mode toggle.
|
||||
- Basic reconciliation status display.
|
||||
- **Real-time Log Streaming**: View logs in real-time using Server-Sent Events (SSE).
|
||||
- **Content Security Policy (CSP)**: Ensures secure loading of resources and compatibility with reverse proxies.
|
||||
|
||||
|
|
|
|||
123
app.py
123
app.py
|
|
@ -53,6 +53,10 @@ EXTERNAL_TUNNEL_ID = os.getenv('EXTERNAL_TUNNEL_ID')
|
|||
|
||||
# Network scanning configuration
|
||||
SCAN_ALL_NETWORKS = os.getenv('SCAN_ALL_NETWORKS', 'false').lower() in ['true', '1', 't', 'yes']
|
||||
# Tunnel DNS Scanning to show DNS entrys for other
|
||||
# for function get_dns_records_for_tunnel
|
||||
TUNNEL_DNS_SCAN_ZONE_NAMES_STR = os.getenv('TUNNEL_DNS_SCAN_ZONE_NAMES', '')
|
||||
TUNNEL_DNS_SCAN_ZONE_NAMES = [name.strip() for name in TUNNEL_DNS_SCAN_ZONE_NAMES_STR.split(',') if name.strip()]
|
||||
|
||||
# Settings that are only required when NOT using external cloudflared
|
||||
TUNNEL_NAME = os.getenv("TUNNEL_NAME", "dockflared-tunnel")
|
||||
|
|
@ -2085,6 +2089,82 @@ def update_cloudflare_config():
|
|||
# If we reached here, either there were no changes or the update was successful
|
||||
return True
|
||||
|
||||
def get_all_account_cloudflare_tunnels():
|
||||
if not CF_ACCOUNT_ID:
|
||||
logging.warning("CF_ACCOUNT_ID is not configured. Cannot list all Cloudflare tunnels from the account.")
|
||||
return []
|
||||
if not CF_API_TOKEN:
|
||||
logging.error("Cloudflare API token not configured. Cannot list all account tunnels.")
|
||||
return []
|
||||
|
||||
endpoint = f"/accounts/{CF_ACCOUNT_ID}/cfd_tunnel"
|
||||
params = {
|
||||
"is_deleted": "false"
|
||||
}
|
||||
|
||||
logging.info(f"Attempting to list all Cloudflare tunnels for account ID {CF_ACCOUNT_ID} with params: {params}")
|
||||
try:
|
||||
response_data = cf_api_request("GET", endpoint, params=params)
|
||||
tunnels = response_data.get("result", [])
|
||||
|
||||
if isinstance(tunnels, list):
|
||||
logging.info(f"Successfully retrieved {len(tunnels)} Cloudflare tunnels from the account (any status).")
|
||||
|
||||
desired_statuses = {"healthy", "degraded", "down", "inactive"}
|
||||
filtered_tunnels = [
|
||||
tunnel for tunnel in tunnels if tunnel.get("status") in desired_statuses
|
||||
]
|
||||
|
||||
logging.info(f"Returning {len(filtered_tunnels)} tunnels after client-side status check for relevant statuses.")
|
||||
filtered_tunnels.sort(key=lambda t: t.get("name", "").lower())
|
||||
return filtered_tunnels
|
||||
else:
|
||||
logging.error(f"Unexpected data format for account tunnels list: {type(tunnels)}. Expected a list. Response: {response_data}")
|
||||
return []
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"API error listing all Cloudflare tunnels for the account: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
if e.response.status_code == 403:
|
||||
logging.error("Permission denied (403) listing account tunnels. Ensure API token has 'Account:Cloudflare Tunnel:Read' permission for the account.")
|
||||
elif e.response.status_code == 400:
|
||||
logging.error(f"Bad Request (400) listing account tunnels. API Response: {e.response.text}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error listing all Cloudflare tunnels for the account: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def get_dns_records_for_tunnel(zone_id, tunnel_id):
|
||||
"""Fetches CNAME records in a specific zone pointing to a specific tunnel."""
|
||||
if not zone_id or not tunnel_id:
|
||||
logging.warning("get_dns_records_for_tunnel: Missing zone_id or tunnel_id.")
|
||||
return []
|
||||
|
||||
expected_cname_content = f"{tunnel_id}.cfargotunnel.com"
|
||||
|
||||
endpoint = f"/zones/{zone_id}/dns_records"
|
||||
params = {
|
||||
"type": "CNAME",
|
||||
"content": expected_cname_content,
|
||||
"per_page": 100 # Get up to 100 records, handle pagination if more are expected
|
||||
}
|
||||
logging.info(f"Fetching DNS records for tunnel {tunnel_id} in zone {zone_id} with content {expected_cname_content}")
|
||||
|
||||
try:
|
||||
response_data = cf_api_request("GET", endpoint, params=params) # Your existing cf_api_request
|
||||
dns_records = response_data.get("result", [])
|
||||
if isinstance(dns_records, list):
|
||||
|
||||
return [{"name": record.get("name"), "id": record.get("id"), "zone_id": zone_id} for record in dns_records if record.get("name")]
|
||||
else:
|
||||
logging.error(f"Unexpected data format for DNS records list in zone {zone_id}: {type(dns_records)}")
|
||||
return []
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"API error fetching DNS records for tunnel {tunnel_id} in zone {zone_id}: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error fetching DNS records for tunnel {tunnel_id} in zone {zone_id}: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
app.config['PREFERRED_URL_SCHEME'] = 'https' # Default for url_for, but client-side JS will fix
|
||||
|
|
@ -2144,6 +2224,41 @@ def get_display_token(token):
|
|||
return "Not available"
|
||||
return f"{token[:5]}...{token[-5:]}" if len(token) > 10 else "Token retrieved (short)"
|
||||
|
||||
@app.route('/tunnel-dns-records/<tunnel_id>')
|
||||
def tunnel_dns_records(tunnel_id):
|
||||
if not tunnel_id:
|
||||
return jsonify({"error": "Tunnel ID is required"}), 400
|
||||
|
||||
all_found_dns_records = []
|
||||
|
||||
zone_ids_to_scan = set()
|
||||
|
||||
if CF_ZONE_ID: # Your global CF_ZONE_ID
|
||||
zone_ids_to_scan.add(CF_ZONE_ID)
|
||||
|
||||
for zone_name in TUNNEL_DNS_SCAN_ZONE_NAMES: # The list loaded from env var
|
||||
resolved_zone_id = get_zone_id_from_name(zone_name) # Your existing function
|
||||
if resolved_zone_id:
|
||||
zone_ids_to_scan.add(resolved_zone_id)
|
||||
else:
|
||||
logging.warning(f"Could not resolve Zone ID for configured scan name: {zone_name}")
|
||||
|
||||
if not zone_ids_to_scan:
|
||||
logging.warning(f"No Zone IDs configured or resolved for DNS scan for tunnel {tunnel_id}.")
|
||||
|
||||
return jsonify({"dns_records": [], "message": "No zones configured for DNS scan."})
|
||||
|
||||
for zone_id in zone_ids_to_scan:
|
||||
records_in_zone = get_dns_records_for_tunnel(zone_id, tunnel_id)
|
||||
if records_in_zone: # records_in_zone is a list of dicts
|
||||
|
||||
all_found_dns_records.extend(records_in_zone)
|
||||
|
||||
all_found_dns_records.sort(key=lambda r: r.get("name", "").lower())
|
||||
|
||||
logging.info(f"Found {len(all_found_dns_records)} DNS records for tunnel {tunnel_id} across {len(zone_ids_to_scan)} zones.")
|
||||
return jsonify({"dns_records": all_found_dns_records})
|
||||
|
||||
@app.context_processor
|
||||
def inject_protocol():
|
||||
"""Inject protocol info into all templates with more reliable detection."""
|
||||
|
|
@ -2195,7 +2310,7 @@ def status_page():
|
|||
docker_available = docker_client is not None
|
||||
external_cloudflared = USE_EXTERNAL_CLOUDFLARED
|
||||
external_tunnel_id = EXTERNAL_TUNNEL_ID
|
||||
|
||||
all_account_tunnels_list = get_all_account_cloudflare_tunnels()
|
||||
return render_template('status_page.html',
|
||||
tunnel_state=template_tunnel_state,
|
||||
agent_state=template_agent_state,
|
||||
|
|
@ -2205,7 +2320,11 @@ def status_page():
|
|||
docker_available=docker_available,
|
||||
external_cloudflared=external_cloudflared,
|
||||
external_tunnel_id=external_tunnel_id,
|
||||
rules=rules_for_template)
|
||||
rules=rules_for_template,
|
||||
all_account_tunnels=all_account_tunnels_list,
|
||||
CF_ACCOUNT_ID_CONFIGURED=bool(CF_ACCOUNT_ID), # Pass boolean flag
|
||||
ACCOUNT_ID_FOR_DISPLAY=CF_ACCOUNT_ID if CF_ACCOUNT_ID else "Not Configured" # Pass actual ID or placeholder
|
||||
)
|
||||
|
||||
@app.route('/ping')
|
||||
def ping():
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 377 KiB |
|
|
@ -21,14 +21,34 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Dockflare - Status</title>
|
||||
<title>DockFlare - Cloudflare Tunnel ingress Manager</title>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const currentProtocol = window.location.protocol;
|
||||
const currentHost = window.location.host;
|
||||
console.log("Page loading with protocol:", currentProtocol);
|
||||
|
||||
function fixResources() {
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('http:') && window.location.protocol === 'https:') {
|
||||
link.setAttribute('href', href.replace('http:', 'https:'));
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('script[src]').forEach(function(script) {
|
||||
const src = script.getAttribute('src');
|
||||
if (src && src.startsWith('http:') && window.location.protocol === 'https:') {
|
||||
script.setAttribute('src', src.replace('http:', 'https:'));
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('link[rel="preconnect"]').forEach(function(link) {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('http:') && window.location.protocol === 'https:') {
|
||||
link.setAttribute('href', href.replace('http:', 'https:'));
|
||||
}
|
||||
});
|
||||
}
|
||||
fixResources();
|
||||
document.addEventListener('DOMContentLoaded', fixResources);
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function(url, options) {
|
||||
if (url && typeof url === 'string' && url.startsWith('http')) {
|
||||
|
|
@ -38,34 +58,35 @@
|
|||
urlObj.protocol = currentProtocol;
|
||||
return origFetch.call(this, urlObj.toString(), options);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing URL:', e);
|
||||
}
|
||||
} catch (e) { console.error('Error processing URL:', e); }
|
||||
}
|
||||
return origFetch.call(this, url, options);
|
||||
};
|
||||
|
||||
const baseTag = document.createElement('base');
|
||||
baseTag.href = currentProtocol + '//' + currentHost + '/';
|
||||
document.head.appendChild(baseTag);
|
||||
const preconnectDomains = ['cdn.tailwindcss.com', 'rsms.me'];
|
||||
preconnectDomains.forEach(domain => {
|
||||
const existingPreconnect = Array.from(document.querySelectorAll('link[rel="preconnect"]'))
|
||||
.find(link => link.href.includes(domain));
|
||||
if (existingPreconnect) {
|
||||
const href = existingPreconnect.getAttribute('href');
|
||||
if (href && href.startsWith('http') && new URL(href).protocol !== currentProtocol) {
|
||||
existingPreconnect.setAttribute('href', currentProtocol + '//' + new URL(href).host);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://cdn.tailwindcss.com" crossorigin>
|
||||
<link rel="preconnect" href="https://rsms.me" crossorigin>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {}
|
||||
}
|
||||
}
|
||||
tailwind.config = { darkMode: 'class', theme: { extend: {} } }
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" crossorigin="anonymous">
|
||||
<style type="text/tailwindcss">
|
||||
<style type="text/tailwindcss">
|
||||
#log-output .log-status { @apply italic text-gray-400 dark:text-gray-500; }
|
||||
#log-output .log-status.error { @apply not-italic text-red-500 dark:text-red-400 font-semibold; }
|
||||
#log-output .log-status.connected { @apply not-italic text-green-500 dark:text-green-400; }
|
||||
|
|
@ -75,19 +96,23 @@
|
|||
#log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(156, 163, 175, 0.7); }
|
||||
html.dark #log-output::-webkit-scrollbar-thumb { background-color: rgba(107, 114, 128, 0.5); }
|
||||
html.dark #log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(107, 114, 128, 0.7); }
|
||||
</style>
|
||||
<style>
|
||||
</style>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; }
|
||||
.dark { color-scheme: dark; }
|
||||
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
|
||||
pre { font-family: monospace; }
|
||||
</style>
|
||||
.connection-badge { display: inline-block; padding: 0.2em 0.4em; font-size: 85%; font-weight: 600; line-height: 1; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: 0.25rem; background-color: #6366f1; color: white; margin-right: 4px; margin-bottom: 4px; }
|
||||
html.dark .connection-badge { background-color: #818cf8; }
|
||||
.warning-text { color: #ef4444; font-weight: bold; }
|
||||
html.dark .warning-text { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col font-sans transition-colors duration-300">
|
||||
|
||||
<div style="position: fixed; top: 0; left: 0; z-index: 50; pointer-events: none;">
|
||||
<div class="fixed top-6 left-[-38px] sm:top-7 sm:left-[-35px] z-50 transform -rotate-45 bg-amber-500 dark:bg-amber-600 text-black dark:text-white text-xs font-semibold uppercase tracking-wider text-center py-1 w-36 shadow-md">
|
||||
Beta Build
|
||||
<div class="fixed top-6 left-[-38px] sm:top-7 sm:left-[-35px] z-50 transform -rotate-45 bg-indigo-600 dark:bg-indigo-500 text-white dark:text-white text-xs font-semibold uppercase tracking-wider text-center py-1 w-36 shadow-md">
|
||||
stable 1.4.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -128,7 +153,6 @@
|
|||
<h2 class="text-2xl sm:text-3xl font-semibold mb-6 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
Tunnel & Agent Status
|
||||
</h2>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-3 text-gray-600 dark:text-gray-400">Cloudflare API & Tunnel Setup</h3>
|
||||
{% set status_box_class = 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' %}
|
||||
|
|
@ -147,7 +171,6 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-md font-semibold mb-3 text-gray-600 dark:text-gray-400">Tunnel Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
|
|
@ -161,7 +184,6 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-gray-600 dark:text-gray-400">Agent Control</h3>
|
||||
{% if agent_state.last_action_status %}
|
||||
|
|
@ -175,22 +197,17 @@
|
|||
<p><strong>Last Action:</strong> {{ agent_state.last_action_status }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-6 mt-2 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-md">
|
||||
{% if external_cloudflared %}
|
||||
<div class="agent-status-info flex-grow space-y-1.5">
|
||||
<div class="flex items-center space-x-3">
|
||||
<strong class="text-sm font-medium flex-shrink-0 w-28">External Mode:</strong>
|
||||
<span class="status-indicator inline-block flex-shrink-0 w-3 h-3 rounded-full bg-blue-500 animate-pulse-fast shadow-sm"></span>
|
||||
<strong class="text-sm font-semibold capitalize text-blue-600 dark:text-blue-400">
|
||||
Using External Cloudflared
|
||||
</strong>
|
||||
<strong class="text-sm font-semibold capitalize text-blue-600 dark:text-blue-400">Using External Cloudflared</strong>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<strong class="font-medium text-gray-600 dark:text-gray-400 flex-shrink-0 w-28">External ID:</strong>
|
||||
<code class="ml-3 bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs align-middle truncate" title="{{ external_tunnel_id }}">
|
||||
{{ external_tunnel_id }}
|
||||
</code>
|
||||
<code class="ml-3 bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs align-middle truncate" title="{{ external_tunnel_id }}">{{ external_tunnel_id }}</code>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p class="italic">External cloudflared mode enabled. DockFlare manages tunnel configuration and DNS records, but not the agent container.</p>
|
||||
|
|
@ -204,36 +221,27 @@
|
|||
{% if agent_state.container_status == 'running' %} {% set indicator_class = 'bg-green-500 animate-pulse-fast' %}
|
||||
{% elif agent_state.container_status in ['exited', 'dead', 'not_found'] %} {% set indicator_class = 'bg-red-500' %}
|
||||
{% elif agent_state.container_status == 'docker_unavailable' %} {% set indicator_class = 'bg-gray-500' %}
|
||||
{% elif agent_state.container_status in ['created', 'paused', 'restarting'] %}
|
||||
{% set indicator_class = 'bg-amber-500' %}
|
||||
{% elif agent_state.container_status in ['created', 'paused', 'restarting'] %} {% set indicator_class = 'bg-amber-500' %}
|
||||
{% endif %}
|
||||
<span class="status-indicator inline-block flex-shrink-0 w-3 h-3 rounded-full {{ indicator_class }} shadow-sm"></span>
|
||||
|
||||
{% set status_text_class = 'text-gray-600 dark:text-gray-400' %}
|
||||
{% if agent_state.container_status=='running' %} {% set status_text_class = 'text-green-600 dark:text-green-400' %}
|
||||
{% elif agent_state.container_status in ['exited', 'dead', 'not_found', 'docker_unavailable'] %} {% set status_text_class = 'text-red-600 dark:text-red-400' %}
|
||||
{% elif agent_state.container_status in ['created', 'paused', 'restarting'] %}
|
||||
{% set status_text_class = 'text-amber-600 dark:text-amber-400' %}
|
||||
{% elif agent_state.container_status in ['created', 'paused', 'restarting'] %} {% set status_text_class = 'text-amber-600 dark:text-amber-400' %}
|
||||
{% endif %}
|
||||
<strong class="text-sm font-semibold capitalize {{ status_text_class }}">
|
||||
{{ agent_state.container_status.replace('_',' ') if agent_state.container_status else 'Unknown' }}
|
||||
</strong>
|
||||
<strong class="text-sm font-semibold capitalize {{ status_text_class }}">{{ agent_state.container_status.replace('_',' ') if agent_state.container_status else 'Unknown' }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<strong class="font-medium text-gray-600 dark:text-gray-400 flex-shrink-0 w-28">Agent Name:</strong>
|
||||
<code class="ml-3 bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs align-middle truncate" title="{{ cloudflared_container_name }}">
|
||||
{{ cloudflared_container_name }}
|
||||
</code>
|
||||
<code class="ml-3 bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs align-middle truncate" title="{{ cloudflared_container_name }}">{{ cloudflared_container_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-action-button w-full sm:w-auto flex-shrink-0">
|
||||
{% set base_button_style = 'w-full sm:w-auto inline-flex items-center justify-center px-5 py-2 rounded-md text-white font-semibold shadow-md transition-all duration-200 ease-in-out transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none' %}
|
||||
{% set start_button_style = 'bg-green-600 hover:bg-green-700 focus:ring-green-500 disabled:bg-green-300 dark:disabled:bg-green-800' %}
|
||||
{% set stop_button_style = 'bg-red-600 hover:bg-red-700 focus:ring-red-500 disabled:bg-red-300 dark:disabled:bg-red-800' %}
|
||||
{% set is_startable = tunnel_state.get('id') and tunnel_state.get('token') and docker_available %}
|
||||
{% set is_stoppable = docker_available %}
|
||||
|
||||
{% if agent_state.container_status=='running' %}
|
||||
<form action="{{ url_for('stop_tunnel') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
|
||||
<button type="submit" class="{{ base_button_style }} {{ stop_button_style }}" {{ 'disabled' if not is_stoppable else '' }}>Stop Agent</button>
|
||||
|
|
@ -255,7 +263,6 @@
|
|||
<h2 class="text-2xl sm:text-3xl font-semibold mb-6 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
Managed Ingress Rules
|
||||
</h2>
|
||||
|
||||
{% if rules %}
|
||||
<div class="overflow-x-auto -mx-6 sm:-mx-8">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
|
|
@ -273,47 +280,31 @@
|
|||
{% for hostname, details in rules.items()|sort %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150 ease-in-out">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="https://{{ hostname }}" target="_blank" rel="noopener noreferrer" title="Open https://{{ hostname }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">
|
||||
<code>{{ hostname }}</code>
|
||||
</a>
|
||||
{% if hostname.startswith('*.') %}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-800/30 dark:text-blue-300">
|
||||
wildcard
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ details.service }}</code>
|
||||
</td>
|
||||
<a href="https://{{ hostname }}" target="_blank" rel="noopener noreferrer" title="Open https://{{ hostname }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">
|
||||
{{ hostname }}
|
||||
</a>
|
||||
{% if hostname.startswith('*.') %}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-800/30 dark:text-blue-300">wildcard</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ details.service }}</code></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% set status_text_class = 'text-orange-600 dark:text-orange-400' if 'pending' in details.status else 'text-green-600 dark:text-green-400' %}
|
||||
{% set status_bg_class = 'bg-orange-100 dark:bg-orange-900/30' if 'pending' in details.status else 'bg-green-100 dark:bg-green-900/30' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ status_bg_class }} {{ status_text_class }}">
|
||||
{{ details.status.replace('_',' ') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm text-gray-500 dark:text-gray-400 font-mono" title="{{ details.container_id if details.container_id else 'N/A' }}">
|
||||
{{ details.container_id[:12] if details.container_id else 'N/A' }}
|
||||
</code>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ status_bg_class }} {{ status_text_class }}">{{ details.status.replace('_',' ') }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><code class="text-sm text-gray-700 dark:text-gray-300 font-mono" title="{{ details.container_id if details.container_id else 'N/A' }}">{{ details.container_id[:12] if details.container_id else 'N/A' }}</code></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if details.status=='pending_deletion' and details.delete_at %}
|
||||
<div data-delete-at="{{ details.delete_at.isoformat() }}">
|
||||
<span class="absolute-time font-medium text-gray-700 dark:text-gray-300">{{ details.delete_at.strftime('%H:%M %d-%b-%Y') }}</span>
|
||||
<span class="countdown-timer block text-xs text-gray-500 dark:text-gray-400">(calculating...)</span>
|
||||
</div>
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
<div data-delete-at="{{ details.delete_at.isoformat() }}">
|
||||
<span class="absolute-time">{{ details.delete_at.strftime('%H:%M %d-%b-%Y') }}</span>
|
||||
<span class="countdown-timer block text-xs text-gray-500 dark:text-gray-400">(calculating...)</span>
|
||||
</div>
|
||||
{% else %} N/A {% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<form action="{{ url_for('force_delete_rule', hostname=hostname) }}" method="post" onsubmit="return confirm('Are you sure you want to force delete the rule and DNS record for {{ hostname }} immediately? This bypasses the grace period.');" class="protocol-aware-form">
|
||||
<button type="submit"
|
||||
class="px-2 py-1 rounded text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-800 dark:hover:text-red-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-transparent disabled:text-gray-400"
|
||||
{{ 'disabled' if not docker_available else '' }}>
|
||||
Delete
|
||||
</button>
|
||||
<button type="submit" class="px-2 py-1 rounded text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-800 dark:hover:text-red-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-transparent disabled:text-gray-400" {{ 'disabled' if not docker_available else '' }}>Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -327,6 +318,87 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-8 sm:mb-12 transition-shadow duration-300 hover:shadow-xl">
|
||||
<div class="px-6 py-5 sm:px-8 sm:py-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-semibold mb-6 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
All Cloudflare Tunnels on Account
|
||||
</h2>
|
||||
{% if CF_ACCOUNT_ID_CONFIGURED %}
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Displaying tunnels for Cloudflare Account ID: <code class="bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.
|
||||
<br>
|
||||
<em>This list shows all tunnels found on the account, not just the one managed by this DockFlare instance. Click the '+' icon next to a tunnel to view its associated DNS records.</em>
|
||||
</p>
|
||||
{% if all_account_tunnels is defined and all_account_tunnels %}
|
||||
<div class="overflow-x-auto -mx-6 sm:-mx-8">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12">+/-</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tunnel Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tunnel ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created At</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Connections</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for tunnel in all_account_tunnels %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150 ease-in-out">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-center">
|
||||
<button type="button" class="tunnel-dns-toggle p-1 rounded-full text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200 hover:bg-indigo-100 dark:hover:bg-indigo-700/50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
data-tunnel-id="{{ tunnel.id | e }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="dns-records-{{ tunnel.id | e }}"
|
||||
title="Toggle DNS records">
|
||||
<svg class="w-5 h-5 expand-icon transition-transform duration-200 ease-in-out" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
||||
<svg class="w-5 h-5 collapse-icon hidden transition-transform duration-200 ease-in-out" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"></path></svg>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{{ tunnel.name | e }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ tunnel.id | e }}</code></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="status-text status-{{ tunnel.status | lower | e }} px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{% if tunnel.status | lower == 'healthy' %} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif tunnel.status | lower == 'degraded' %} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||
{% elif tunnel.status | lower == 'down' %} bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
|
||||
{% else %} bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 {% endif %}">
|
||||
{{ tunnel.status | capitalize | e }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if tunnel.created_at %}{{ tunnel.created_at.split('T')[0] | e }}{% else %}N/A{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if tunnel.connections and tunnel.connections is iterable and not tunnel.connections is string %}
|
||||
{% for conn in tunnel.connections %}<span class="connection-badge">{{ conn.colo_name | e }}</span>{% endfor %}
|
||||
{% if tunnel.connections | length > 0 %}({{ tunnel.connections | length }} total){% else %}(0 total){% endif %}
|
||||
{% elif tunnel.connections %}{{ tunnel.connections | e }}{% else %}None{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="dns-records-row hidden bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<td colspan="6">
|
||||
<div id="dns-records-{{ tunnel.id | e }}" class="p-4 text-sm space-y-2">
|
||||
<p class="text-gray-500 dark:text-gray-400 italic">Click the '+' button to load DNS records for this tunnel.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif all_account_tunnels is defined %}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 py-8">No Cloudflare Tunnels found for Account ID: <code>{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.</p>
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400"><em>This could also mean an error occurred fetching them (check DockFlare logs). Ensure your CF_API_TOKEN has 'Account:Cloudflare Tunnel:Read' permission for the account level.</em></p>
|
||||
{% else %}
|
||||
<p class="warning-text text-center py-8">Could not retrieve tunnel information (<code>all_account_tunnels</code> variable not available in template).</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="warning-text text-center py-8">The <code>CF_ACCOUNT_ID</code> environment variable is not configured. This section cannot be displayed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-8 sm:mb-12 transition-shadow duration-300 hover:shadow-xl">
|
||||
<div class="px-6 py-5 sm:px-8 sm:py-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-semibold mb-4 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
|
|
@ -335,304 +407,122 @@
|
|||
<pre id="log-output" aria-live="polite" class="h-96 overflow-y-scroll bg-gray-900 dark:bg-black text-gray-300 dark:text-gray-300 border border-gray-700 dark:border-gray-600 rounded-md p-4 font-mono text-xs leading-relaxed shadow-inner">Connecting to log stream...</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-6 mt-auto text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p class="mb-1">
|
||||
<a href="https://github.com/ChrispyBacon-dev/DockFlare" target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">
|
||||
Dockflare on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p class="mb-1">Enjoying Dockflare? Consider supporting the project!</p>
|
||||
<p>
|
||||
<a href="https://github.com/sponsors/ChrispyBacon-dev" target="_blank" rel="noopener noreferrer" class="inline-flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-pink-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" /></svg>
|
||||
Sponsor ChrispyBacon-dev on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare" target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">Dockflare on GitHub</a></p>
|
||||
<p class="mb-1">Enjoying DockFlare? Consider supporting the project!</p>
|
||||
<p><a href="https://github.com/sponsors/ChrispyBacon-dev" target="_blank" rel="noopener noreferrer" class="inline-flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-pink-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" /></svg>Sponsor ChrispyBacon-dev on GitHub</a></p>
|
||||
<p>DockFlare v1.4 (stable)</p>
|
||||
</div>
|
||||
<div class="text-center text-xs text-gray-500">
|
||||
<a href="javascript:void(0)" onclick="checkEnvironment()" class="hover:text-indigo-600">Check Environment</a>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const maxLogLines = 250;
|
||||
let initialConnectMessageCleared = false;
|
||||
let activeLogSource = null;
|
||||
let eventSourceHealthCheck = null;
|
||||
let pingInterval = null;
|
||||
const maxLogLines = 250;let initialConnectMessageCleared = false;let activeLogSource = null;let eventSourceHealthCheck = null;let pingInterval = null;
|
||||
function addLogLine(message, type = 'log') {const logOutput = document.getElementById('log-output');if (!logOutput) { console.error("Log output element not found when trying to add line."); return; }if (!initialConnectMessageCleared && logOutput.textContent.includes('Connecting to log stream...')) {logOutput.textContent = ''; initialConnectMessageCleared = true;}const newLogLine = document.createElement('div');newLogLine.textContent = message;newLogLine.classList.add('mb-1');if (type === 'status') newLogLine.classList.add('log-status');else if (type === 'error') newLogLine.classList.add('log-status', 'error');else if (type === 'connected') newLogLine.classList.add('log-status', 'connected');const isScrolledToBottom = logOutput.scrollHeight - logOutput.clientHeight <= logOutput.scrollTop + 10;logOutput.appendChild(newLogLine);while (logOutput.childNodes.length > maxLogLines) logOutput.removeChild(logOutput.firstChild);if (isScrolledToBottom) logOutput.scrollTop = logOutput.scrollHeight;}
|
||||
function connectEventSource() {const logOutput = document.getElementById('log-output');if (!logOutput) { console.error("Log output element not found for EventSource setup."); return; }if (!window.EventSource) {console.error("EventSource not supported");logOutput.textContent = "Browser doesn't support Server-Sent Events."; return;}if (activeLogSource) { try { activeLogSource.close(); } catch (e) { console.error("Error closing existing log stream:", e); } activeLogSource = null; }const currentProtocol = window.location.protocol; const currentHost = window.location.host;const timestampParam = Date.now();const streamUrl = `${currentProtocol}//${currentHost}/stream-logs?t=${timestampParam}`;try {activeLogSource = new EventSource(streamUrl);let connectionTimeout;const resetConnectionTimeout = () => {if (connectionTimeout) clearTimeout(connectionTimeout);connectionTimeout = setTimeout(() => {if (activeLogSource) {activeLogSource.close(); activeLogSource = null;addLogLine("--- Log stream connection timeout. Reconnecting... ---", 'error');setTimeout(connectEventSource, 2000);}}, 10000);};resetConnectionTimeout();activeLogSource.onopen = function() {if (connectionTimeout) clearTimeout(connectionTimeout);addLogLine("--- Log stream connected ---", 'connected');};activeLogSource.onmessage = function(event) {resetConnectionTimeout(); if (event.data === "heartbeat") { return; }addLogLine(event.data, 'log');};let retryAttempt = 0;activeLogSource.onerror = function(err) {if (connectionTimeout) clearTimeout(connectionTimeout);addLogLine("--- Log stream connection error. Retrying... ---", 'error');if (activeLogSource) { activeLogSource.close(); activeLogSource = null; }retryAttempt++; const delay = Math.min(5000 * Math.pow(1.5, retryAttempt - 1), 30000);setTimeout(connectEventSource, delay);};} catch (e) {addLogLine("--- Failed to establish log stream connection ---", 'error'); setTimeout(connectEventSource, 5000);}if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);eventSourceHealthCheck = setInterval(() => {if (!activeLogSource || activeLogSource.readyState === 2) {connectEventSource();}}, 30000);window.addEventListener('beforeunload', function() {if (activeLogSource) activeLogSource.close(); if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);});}
|
||||
function checkEnvironment() {const currentProtocol = window.location.protocol; const currentHost = window.location.host;const debugUrl = `${currentProtocol}//${currentHost}/debug`;fetch(debugUrl).then(response => response.json()).then(data => { alert("Env debug info logged to console."); }).catch(error => console.error("Failed to fetch environment info:", error));}
|
||||
function formatTimeDifference(diffMillis) {const totalSeconds = Math.round(Math.abs(diffMillis / 1000)); if (totalSeconds < 60) return diffMillis >= 0 ? 'in <1m' : '<1m ago';const days = Math.floor(totalSeconds / (3600 * 24)); const hours = Math.floor((totalSeconds % (3600 * 24)) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60);let parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0 || (days === 0 && hours === 0)) parts.push(`${minutes}m`);const timeString = parts.join(' '); return diffMillis >= 0 ? `in ${timeString}` : `${timeString} ago`;}
|
||||
function updateCountdowns() {const countdownElements = document.querySelectorAll('.countdown-timer'); if (!countdownElements.length) return;const now = new Date();countdownElements.forEach(span => {const td = span.closest('td[data-delete-at]'); if (!td) return;const deleteAtISO = td.dataset.deleteAt; if (!deleteAtISO) return;try {const targetDate = new Date(deleteAtISO); if (isNaN(targetDate.getTime())) throw new Error("Invalid date parsed");const diff = targetDate - now; span.textContent = `(${formatTimeDifference(diff)})`;if (diff < 0) span.classList.add('text-red-500'); else span.classList.remove('text-red-500');} catch (e) { span.textContent = "(invalid date)"; }});}
|
||||
function startServerPing() {if (pingInterval) clearInterval(pingInterval);pingInterval = setInterval(() => {const currentProtocol = window.location.protocol; const currentHost = window.location.host;const pingUrl = `${currentProtocol}//${currentHost}/ping?t=${new Date().getTime()}`;fetch(pingUrl).then(response => response.ok ? response.json() : Promise.reject(`Ping failed: ${response.status}`)).then(data => {}).catch(error => {});}, 30000);}
|
||||
function updateReconciliationStatus() {const currentProtocol = window.location.protocol;const currentHost = window.location.host;const statusUrl = `${currentProtocol}//${currentHost}/reconciliation-status?t=${new Date().getTime()}`;fetch(statusUrl).then(response => response.json()).then(data => {const statusElement = document.getElementById('reconciliation-status');const messageElement = document.getElementById('reconciliation-status-message');if (!statusElement || !messageElement) {return;}if (data.status) {messageElement.textContent = data.status;messageElement.style.display = data.in_progress ? 'block' : 'none';}if (data.in_progress) {statusElement.innerHTML = `<div class="alert-banner p-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-500 text-gray-700 dark:text-gray-300 shadow-md"><div class="flex items-center"><div class="flex-shrink-0 mr-3"><svg class="h-5 w-5 text-amber-500 animate-spin" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M12 6v6l4 2"></path></svg></div><div><p class="font-medium">Reconciliation: ${data.progress}%</p><p class="text-sm mt-1">Processing ${data.processed_items} of ${data.total_items} items...</p></div></div></div>`;} else if (statusElement.innerHTML.includes('Reconciliation')) {statusElement.innerHTML = `<div class="alert-banner p-4 rounded-lg bg-green-50 dark:bg-green-900/30 border-l-4 border-green-500 text-gray-700 dark:text-gray-300 shadow-md"><div class="flex items-center"><div class="flex-shrink-0 mr-3"><svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg></div><div><p class="font-medium">Reconciliation complete</p></div></div></div>`;setTimeout(() => {if (statusElement.innerHTML.includes('Reconciliation complete')) {statusElement.innerHTML = '';if (messageElement) {messageElement.style.display = 'none';}}}, 5000);}}).catch(err => {});}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const currentProtocol = window.location.protocol;const currentHost = window.location.host;
|
||||
document.querySelectorAll('form.protocol-aware-form').forEach(form => {if (form.getAttribute('action')) {let actionUrl = form.getAttribute('action');if (actionUrl.startsWith('/')) {actionUrl = `${currentProtocol}//${currentHost}${actionUrl}`; form.setAttribute('action', actionUrl);} else if (actionUrl.startsWith('http')) {try {const parsedUrl = new URL(actionUrl);if (parsedUrl.host === currentHost && parsedUrl.protocol !== currentProtocol) {parsedUrl.protocol = currentProtocol; form.setAttribute('action', parsedUrl.toString());}} catch (e) {}}}});
|
||||
document.querySelectorAll('a[href]').forEach(link => {const href = link.getAttribute('href');if (href && (href.startsWith('/') || (href.startsWith('http') && new URL(href, window.location.origin).host === currentHost))) {try {const url = new URL(href, window.location.origin);if (url.host === currentHost && url.protocol !== currentProtocol) {url.protocol = currentProtocol;link.setAttribute('href', url.toString());}} catch (e) {}}});
|
||||
updateCountdowns();const countdownInterval = setInterval(updateCountdowns, 30000);connectEventSource();
|
||||
const themeToggleButton = document.getElementById('theme-toggle');const htmlElement = document.documentElement;
|
||||
if (themeToggleButton) {const themeToggleIcon = themeToggleButton.querySelector('span');const setTheme = (theme) => {if (theme !== 'light' && theme !== 'dark') { return; }localStorage.setItem('theme', theme);if (theme === 'dark') {htmlElement.classList.add('dark'); if (themeToggleIcon) themeToggleIcon.textContent = '☀️';} else {htmlElement.classList.remove('dark'); if (themeToggleIcon) themeToggleIcon.textContent = '🌓';}};const toggleTheme = () => {const isCurrentlyDark = htmlElement.classList.contains('dark');const currentTheme = isCurrentlyDark ? 'dark' : 'light'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; setTheme(newTheme);};themeToggleButton.addEventListener('click', toggleTheme);const initTheme = () => {const savedTheme = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;if (savedTheme === 'dark' || savedTheme === 'light') setTheme(savedTheme);else setTheme(prefersDark ? 'dark' : 'light');};initTheme();}
|
||||
updateReconciliationStatus();setInterval(updateReconciliationStatus, 2000);
|
||||
|
||||
function addLogLine(message, type = 'log') {
|
||||
const logOutput = document.getElementById('log-output');
|
||||
if (!logOutput) { console.error("Log output element not found when trying to add line."); return; }
|
||||
if (!initialConnectMessageCleared && logOutput.textContent.includes('Connecting to log stream...')) {
|
||||
logOutput.textContent = ''; initialConnectMessageCleared = true;
|
||||
}
|
||||
const newLogLine = document.createElement('div');
|
||||
newLogLine.textContent = message;
|
||||
newLogLine.classList.add('mb-1');
|
||||
if (type === 'status') newLogLine.classList.add('log-status');
|
||||
else if (type === 'error') newLogLine.classList.add('log-status', 'error');
|
||||
else if (type === 'connected') newLogLine.classList.add('log-status', 'connected');
|
||||
const isScrolledToBottom = logOutput.scrollHeight - logOutput.clientHeight <= logOutput.scrollTop + 10;
|
||||
logOutput.appendChild(newLogLine);
|
||||
while (logOutput.childNodes.length > maxLogLines) logOutput.removeChild(logOutput.firstChild);
|
||||
if (isScrolledToBottom) logOutput.scrollTop = logOutput.scrollHeight;
|
||||
}
|
||||
// --- START: New JavaScript for Tunnel DNS Toggle ---
|
||||
document.querySelectorAll('.tunnel-dns-toggle').forEach(button => {
|
||||
button.addEventListener('click', async function() {
|
||||
const tunnelId = this.dataset.tunnelId;
|
||||
const tunnelDetailsRow = this.closest('tr');
|
||||
const dnsRecordsDisplayRow = tunnelDetailsRow.nextElementSibling;
|
||||
const targetDivId = this.getAttribute('aria-controls');
|
||||
const targetDiv = document.getElementById(targetDivId);
|
||||
const isExpanded = this.getAttribute('aria-expanded') === 'true';
|
||||
const expandIcon = this.querySelector('.expand-icon');
|
||||
const collapseIcon = this.querySelector('.collapse-icon');
|
||||
|
||||
function connectEventSource() {
|
||||
const logOutput = document.getElementById('log-output');
|
||||
if (!logOutput) { console.error("Log output element not found for EventSource setup."); return; }
|
||||
if (!window.EventSource) {
|
||||
console.error("EventSource not supported");
|
||||
logOutput.textContent = "Browser doesn't support Server-Sent Events."; return;
|
||||
}
|
||||
if (activeLogSource) { try { activeLogSource.close(); } catch (e) { console.error("Error closing existing log stream:", e); } activeLogSource = null; }
|
||||
|
||||
const currentProtocol = window.location.protocol; const currentHost = window.location.host;
|
||||
const timestampParam = Date.now();
|
||||
const streamUrl = `${currentProtocol}//${currentHost}/stream-logs?t=${timestampParam}`;
|
||||
console.log(`Connecting log stream to: ${streamUrl}`);
|
||||
|
||||
try {
|
||||
activeLogSource = new EventSource(streamUrl);
|
||||
let connectionTimeout;
|
||||
const resetConnectionTimeout = () => {
|
||||
if (connectionTimeout) clearTimeout(connectionTimeout);
|
||||
connectionTimeout = setTimeout(() => {
|
||||
console.error("EventSource connection timeout after 10s");
|
||||
if (activeLogSource) {
|
||||
activeLogSource.close(); activeLogSource = null;
|
||||
addLogLine("--- Log stream connection timeout. Reconnecting... ---", 'error');
|
||||
setTimeout(connectEventSource, 2000);
|
||||
}
|
||||
}, 10000);
|
||||
};
|
||||
resetConnectionTimeout();
|
||||
|
||||
activeLogSource.onopen = function() {
|
||||
console.log("Log stream connection opened"); if (connectionTimeout) clearTimeout(connectionTimeout);
|
||||
addLogLine("--- Log stream connected ---", 'connected');
|
||||
};
|
||||
activeLogSource.onmessage = function(event) {
|
||||
resetConnectionTimeout(); if (event.data === "heartbeat") { console.log("♥ Heartbeat received"); return; }
|
||||
addLogLine(event.data, 'log');
|
||||
};
|
||||
let retryAttempt = 0;
|
||||
activeLogSource.onerror = function(err) {
|
||||
if (connectionTimeout) clearTimeout(connectionTimeout);
|
||||
console.error("Log stream connection error:", err); if (err && err.target) console.log(`EventSource state: ${err.target.readyState}, URL: ${err.target.url}`);
|
||||
addLogLine("--- Log stream connection error. Retrying... ---", 'error');
|
||||
if (activeLogSource) { activeLogSource.close(); activeLogSource = null; }
|
||||
retryAttempt++; const delay = Math.min(5000 * Math.pow(1.5, retryAttempt - 1), 30000);
|
||||
console.log(`Reconnecting in ${Math.round(delay/1000)}s (attempt ${retryAttempt})...`);
|
||||
setTimeout(connectEventSource, delay);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error creating EventSource:", e);
|
||||
addLogLine("--- Failed to establish log stream connection ---", 'error'); setTimeout(connectEventSource, 5000);
|
||||
}
|
||||
if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);
|
||||
eventSourceHealthCheck = setInterval(() => {
|
||||
if (!activeLogSource || activeLogSource.readyState === 2) {
|
||||
console.log("EventSource health check failed, attempting reconnect..."); connectEventSource();
|
||||
}
|
||||
}, 30000);
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (activeLogSource) activeLogSource.close(); if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);
|
||||
});
|
||||
}
|
||||
|
||||
function checkEnvironment() {
|
||||
const currentProtocol = window.location.protocol; const currentHost = window.location.host;
|
||||
const debugUrl = `${currentProtocol}//${currentHost}/debug`;
|
||||
fetch(debugUrl).then(response => response.json())
|
||||
.then(data => { console.log("Environment debug info:", data); alert("Env debug info logged to console."); })
|
||||
.catch(error => console.error("Failed to fetch environment info:", error));
|
||||
}
|
||||
|
||||
function formatTimeDifference(diffMillis) {
|
||||
const totalSeconds = Math.round(Math.abs(diffMillis / 1000)); if (totalSeconds < 60) return diffMillis >= 0 ? 'in <1m' : '<1m ago';
|
||||
const days = Math.floor(totalSeconds / (3600 * 24)); const hours = Math.floor((totalSeconds % (3600 * 24)) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
let parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0 || (days === 0 && hours === 0)) parts.push(`${minutes}m`);
|
||||
const timeString = parts.join(' '); return diffMillis >= 0 ? `in ${timeString}` : `${timeString} ago`;
|
||||
}
|
||||
|
||||
function updateCountdowns() {
|
||||
const countdownElements = document.querySelectorAll('.countdown-timer'); if (!countdownElements.length) return;
|
||||
const now = new Date();
|
||||
countdownElements.forEach(span => {
|
||||
const td = span.closest('td[data-delete-at]'); if (!td) return;
|
||||
const deleteAtISO = td.dataset.deleteAt; if (!deleteAtISO) return;
|
||||
try {
|
||||
const targetDate = new Date(deleteAtISO); if (isNaN(targetDate.getTime())) throw new Error("Invalid date parsed");
|
||||
const diff = targetDate - now; span.textContent = `(${formatTimeDifference(diff)})`;
|
||||
if (diff < 0) span.classList.add('text-red-500'); else span.classList.remove('text-red-500');
|
||||
} catch (e) { console.error("Error parsing date for countdown:", deleteAtISO, e); span.textContent = "(invalid date)"; }
|
||||
});
|
||||
}
|
||||
|
||||
function startServerPing() {
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
pingInterval = setInterval(() => {
|
||||
const currentProtocol = window.location.protocol; const currentHost = window.location.host;
|
||||
const pingUrl = `${currentProtocol}//${currentHost}/ping?t=${new Date().getTime()}`;
|
||||
fetch(pingUrl).then(response => response.ok ? response.json() : Promise.reject(`Ping failed: ${response.status}`))
|
||||
.then(data => console.log("Server ping successful:", data))
|
||||
.catch(error => console.error("Server ping failed:", error));
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function updateReconciliationStatus() {
|
||||
const currentProtocol = window.location.protocol;
|
||||
const currentHost = window.location.host;
|
||||
const statusUrl = `${currentProtocol}//${currentHost}/reconciliation-status?t=${new Date().getTime()}`;
|
||||
|
||||
fetch(statusUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const statusElement = document.getElementById('reconciliation-status');
|
||||
const messageElement = document.getElementById('reconciliation-status-message');
|
||||
|
||||
if (!statusElement || !messageElement) {
|
||||
console.warn("Reconciliation status elements not found in DOM.");
|
||||
if (!dnsRecordsDisplayRow || !dnsRecordsDisplayRow.classList.contains('dns-records-row')) {
|
||||
console.error(`Could not find the dedicated DNS records row for tunnel ${tunnelId}. Button was in:`, tunnelDetailsRow, "Next sibling is:", dnsRecordsDisplayRow);
|
||||
return;
|
||||
}
|
||||
if (!targetDiv) {
|
||||
console.error(`Could not find targetDiv with ID ${targetDivId} for tunnel ${tunnelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
messageElement.textContent = data.status;
|
||||
messageElement.style.display = data.in_progress ? 'block' : 'none';
|
||||
}
|
||||
if (isExpanded) {
|
||||
dnsRecordsDisplayRow.classList.add('hidden');
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
if (expandIcon) expandIcon.classList.remove('hidden');
|
||||
if (collapseIcon) collapseIcon.classList.add('hidden');
|
||||
} else {
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
if (expandIcon) expandIcon.classList.add('hidden');
|
||||
if (collapseIcon) collapseIcon.classList.remove('hidden');
|
||||
|
||||
if (targetDiv.dataset.loaded !== 'true' || targetDiv.dataset.loaded === 'error') {
|
||||
targetDiv.innerHTML = '<p class="text-gray-500 dark:text-gray-400 italic animate-pulse p-2">Loading DNS records...</p>';
|
||||
dnsRecordsDisplayRow.classList.remove('hidden');
|
||||
|
||||
if (data.in_progress) {
|
||||
statusElement.innerHTML = `
|
||||
<div class="alert-banner p-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-500 text-gray-700 dark:text-gray-300 shadow-md">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<svg class="h-5 w-5 text-amber-500 animate-spin" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Reconciliation: ${data.progress}%</p>
|
||||
<p class="text-sm mt-1">Processing ${data.processed_items} of ${data.total_items} items...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (statusElement.innerHTML.includes('Reconciliation')) {
|
||||
statusElement.innerHTML = `
|
||||
<div class="alert-banner p-4 rounded-lg bg-green-50 dark:bg-green-900/30 border-l-4 border-green-500 text-gray-700 dark:text-gray-300 shadow-md">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6L9 17l-5-5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Reconciliation complete</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
try {
|
||||
const currentProtocol = window.location.protocol;
|
||||
const currentHost = window.location.host;
|
||||
const fetchUrl = `${currentProtocol}//${currentHost}/tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`;
|
||||
|
||||
const response = await fetch(fetchUrl);
|
||||
if (!response.ok) {
|
||||
let errorDetail = `HTTP error ${response.status}`;
|
||||
try { const errorData = await response.json(); errorDetail = errorData.error || errorData.message || errorDetail; } catch (e) {}
|
||||
throw new Error(errorDetail);
|
||||
}
|
||||
const data = await response.json();
|
||||
const currentTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
|
||||
if (!currentTargetDiv) {
|
||||
console.error("PANIC: Target DIV disappeared or was wrong during fetch for tunnel " + tunnelId);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (statusElement.innerHTML.includes('Reconciliation complete')) {
|
||||
statusElement.innerHTML = '';
|
||||
if (messageElement) {
|
||||
messageElement.style.display = 'none';
|
||||
if (data.dns_records && data.dns_records.length > 0) {
|
||||
let dnsHtml = '<ul class="list-none pl-4 space-y-1.5">';
|
||||
data.dns_records.forEach(record => {
|
||||
const recordUrl = `https://${record.name}`;
|
||||
dnsHtml += `<li class="text-gray-700 dark:text-gray-300"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline-block mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg><a href="${recordUrl}" target="_blank" rel="noopener noreferrer" class="hover:text-indigo-500 dark:hover:text-indigo-300 hover:underline">${record.name}</a></li>`;
|
||||
});
|
||||
dnsHtml += '</ul>';
|
||||
currentTargetDiv.innerHTML = dnsHtml;
|
||||
currentTargetDiv.dataset.loaded = 'true';
|
||||
} else if (data.message) {
|
||||
currentTargetDiv.innerHTML = `<p class="text-gray-600 dark:text-gray-400 italic p-2">${data.message}</p>`;
|
||||
currentTargetDiv.dataset.loaded = 'info';
|
||||
} else {
|
||||
currentTargetDiv.innerHTML = '<p class="text-gray-600 dark:text-gray-400 italic p-2">No CNAME DNS records found pointing to this tunnel in the configured zones.</p>';
|
||||
currentTargetDiv.dataset.loaded = 'true';
|
||||
}
|
||||
} catch (error) {
|
||||
const errorTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
|
||||
if (errorTargetDiv) {
|
||||
errorTargetDiv.innerHTML = `<p class="text-red-500 dark:text-red-400 p-2">Error loading DNS records: ${error.message}</p>`;
|
||||
errorTargetDiv.dataset.loaded = 'error';
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error fetching reconciliation status:', err));
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log("DOM fully loaded and parsed");
|
||||
|
||||
const currentProtocol = window.location.protocol;
|
||||
const currentHost = window.location.host;
|
||||
|
||||
document.querySelectorAll('form.protocol-aware-form').forEach(form => {
|
||||
if (form.getAttribute('action')) {
|
||||
let actionUrl = form.getAttribute('action');
|
||||
if (actionUrl.startsWith('/')) {
|
||||
actionUrl = `${currentProtocol}//${currentHost}${actionUrl}`; form.setAttribute('action', actionUrl);
|
||||
console.log(`Fixed relative form action to: ${actionUrl}`);
|
||||
} else if (actionUrl.startsWith('http')) {
|
||||
try {
|
||||
const parsedUrl = new URL(actionUrl);
|
||||
if (parsedUrl.host === currentHost && parsedUrl.protocol !== currentProtocol) {
|
||||
parsedUrl.protocol = currentProtocol; form.setAttribute('action', parsedUrl.toString());
|
||||
console.log(`Fixed protocol in form action: ${actionUrl} → ${parsedUrl.toString()}`);
|
||||
}
|
||||
} catch (e) { console.error('Error fixing form action URL:', e); }
|
||||
}
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('a[href]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && (href.startsWith('/') || (href.startsWith('http') && new URL(href, window.location.origin).host === currentHost))) {
|
||||
try {
|
||||
const url = new URL(href, window.location.origin);
|
||||
if (url.host === currentHost && url.protocol !== currentProtocol) {
|
||||
url.protocol = currentProtocol;
|
||||
link.setAttribute('href', url.toString());
|
||||
console.log(`Fixed protocol in link href: ${href} -> ${url.toString()}`);
|
||||
}
|
||||
} catch (e) { console.error('Error fixing link:', e); }
|
||||
}
|
||||
});
|
||||
|
||||
updateCountdowns();
|
||||
const countdownInterval = setInterval(updateCountdowns, 30000);
|
||||
|
||||
connectEventSource();
|
||||
|
||||
const themeToggleButton = document.getElementById('theme-toggle');
|
||||
const htmlElement = document.documentElement;
|
||||
if (themeToggleButton) {
|
||||
const themeToggleIcon = themeToggleButton.querySelector('span');
|
||||
const setTheme = (theme) => {
|
||||
if (theme !== 'light' && theme !== 'dark') { console.warn('Invalid theme value:', theme); return; }
|
||||
localStorage.setItem('theme', theme);
|
||||
if (theme === 'dark') {
|
||||
htmlElement.classList.add('dark'); if (themeToggleIcon) themeToggleIcon.textContent = '☀️';
|
||||
} else {
|
||||
htmlElement.classList.remove('dark'); if (themeToggleIcon) themeToggleIcon.textContent = '🌓';
|
||||
dnsRecordsDisplayRow.classList.remove('hidden');
|
||||
}
|
||||
console.log('Theme set to:', theme);
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
console.log('Toggle button clicked'); const isCurrentlyDark = htmlElement.classList.contains('dark');
|
||||
const currentTheme = isCurrentlyDark ? 'dark' : 'light'; console.log('Current theme detected:', currentTheme);
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; setTheme(newTheme);
|
||||
};
|
||||
themeToggleButton.addEventListener('click', toggleTheme);
|
||||
const initTheme = () => {
|
||||
const savedTheme = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
console.log('Initializing theme. Saved:', savedTheme, 'Prefers dark:', prefersDark);
|
||||
if (savedTheme === 'dark' || savedTheme === 'light') setTheme(savedTheme);
|
||||
else setTheme(prefersDark ? 'dark' : 'light');
|
||||
};
|
||||
initTheme();
|
||||
} else {
|
||||
console.error('Theme toggle button (#theme-toggle) not found!');
|
||||
}
|
||||
|
||||
updateReconciliationStatus();
|
||||
setInterval(updateReconciliationStatus, 2000);
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
});
|
||||
});
|
||||
// --- END: New JavaScript for Tunnel DNS Toggle ---
|
||||
|
||||
});
|
||||
startServerPing();
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue