Merge pull request #35 from ChrispyBacon-dev/unstable
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:
Chris 2025-05-10 22:15:17 +02:00 • committed by GitHub
commit f14ccd91cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 369 additions and 356 deletions

View file

@ -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
View file

@ -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

Before After
Before After

View file

@ -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>