FHIRFLARE-IG-Toolkit/templates/search_and_import_ig.html
Sudo-JHare ca8668e5e5 V2.0 release.
Hapi Config Page.
retrieve bundles from server
split bundles to resources.
new upload page, and Package Registry CACHE
2025-05-04 22:55:36 +10:00

461 lines
30 KiB
HTML

{% extends "base.html" %}
{% from "_form_helpers.html" import render_field %}
{% block extra_head %} {# Assuming base.html has an 'extra_head' block for additional head elements #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
{% endblock %}
{% block content %}
{# Main page content for searching and importing packages #}
<div class="container">
{# Flash messages area - Ensure _flash_messages.html exists and is included in base.html or rendered here #}
{% include "_flash_messages.html" %}
{# Display warning if package fetching failed on initial load #}
{% if fetch_failed %}
<div class="alert alert-warning">
Unable to fetch packages from registries. Showing a fallback list. Please try again later or contact support.
</div>
{% endif %}
<div class="row">
{# Left Column: Search Packages Area #}
<div class="col-md-9 mb-4">
<div class="card">
<div class="card-body">
{# Header with Refresh Button #}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="bi bi-search me-2"></i>Search Packages</h2>
<button class="btn btn-sm btn-outline-warning" id="clear-cache-btn"
title="Clear package cache and fetch fresh list"
hx-post="{{ url_for('refresh_cache_task') }}"
hx-indicator="#cache-spinner"
hx-swap="none"
hx-target="this">
<i class="bi bi-arrow-clockwise"></i> Clear & Refresh Cache
<span id="cache-spinner" class="spinner-border spinner-border-sm ms-1" role="status" aria-hidden="true" style="display: none;"></span>
</button>
</div>
{# Cache Status Timestamp #}
<div class="mb-3 text-muted" style="font-size: 0.85em;">
{% if last_cached_timestamp %}
Package list last fetched: {{ last_cached_timestamp.strftime('%Y-%m-%d %H:%M:%S %Z') if last_cached_timestamp else 'Never' }}
{% if fetch_failed %} (Fetch Failed){% endif %}
{% elif is_fetching %} {# Show text spinner specifically during initial fetch state triggered by backend #}
Fetching package list... <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{% else %}
Never fetched or cache cleared.
{% endif %}
</div>
{# Search Input with HTMX #}
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search packages..." name="search" autofocus
hx-get="{{ url_for('api_search_packages') }}" hx-indicator=".htmx-indicator" hx-target="#search-results" hx-trigger="input changed delay:500ms, search">
<span class="input-group-text htmx-indicator"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
</div>
<div id="search-error" class="alert alert-danger d-none"></div>
{# Search Results Area (populated by HTMX) #}
<div id="search-results">
{% include '_search_results_table.html' %} {# Includes the initial table state or updated results #}
</div>
</div>
</div>
</div>
{# Right Column: Import Form, Log Window, Animation, Warning #}
<div class="col-md-3 mb-4">
<div class="card">
<div class="card-body">
{# Import Form #}
<h2><i class="bi bi-download me-2"></i>Import a New IG</h2>
<form id="import-ig-form"> {# Form ID used by JS #}
{{ form.hidden_tag() }} {# Include CSRF token if using Flask-WTF #}
{{ render_field(form.package_name, class="form-control") }}
{{ render_field(form.package_version, class="form-control") }}
{{ render_field(form.dependency_mode, class="form-select") }}
<div class="d-grid gap-2 d-sm-flex mt-3">
{# Import Button triggers HTMX POST #}
<button class="btn btn-success" id="submit-btn"
hx-post="{{ url_for('import_ig') }}"
hx-indicator="#import-spinner"
hx-include="closest form"
hx-swap="none">
Import
</button>
<span id="import-spinner" class="spinner-border spinner-border-sm align-middle ms-2" role="status" aria-hidden="true" style="display: none;"></span>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a> {# Simple link back #}
</div>
</form>
{# Live Log Output Window #}
<div class="log-window mt-4">
<h5><i class="bi bi-terminal me-2"></i>Live Log Output</h5>
<div id="log-output" class="log-output">
<p class="text-muted small">Logs from caching or import actions will appear here.</p>
</div>
{# Indicator shown while connecting to SSE #}
<div id="log-loading-indicator" class="mt-2 text-muted small" style="display: none;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Connecting to log stream...
</div>
</div>
{# Animation Window (Hidden by default) #}
<div id="log-animation-window" class="minifire-container" style="display: none;">
{# Status Text Overlay #}
<div id="animation-status-text" class="minifire-status-text"></div>
{# Campfire Animation HTML Structure #}
<div class="minifire-campfire">
<div class="minifire-sparks">
<div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div>
<div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div>
</div>
<div class="minifire-logs">
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
</div>
<div class="minifire-sticks">
<div class="minifire-stick"></div> <div class="minifire-stick"></div> <div class="minifire-stick"></div> <div class="minifire-stick"></div>
</div>
<div class="minifire-fire">
<div class="minifire-fire__red"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
<div class="minifire-fire__orange"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
<div class="minifire-fire__yellow"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
<div class="minifire-fire__white"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
</div>
</div>
</div>
{# Warning Text (Hidden by default) #}
<div id="process-warning-text" class="import-warning-text" style="display: none; margin-top: 1rem;">
DO NOT LEAVE PAGE<br>
UNTIL COMPLETED<br>
!ANIMATION DISAPEARS!
</div>
</div> {# End card-body #}
</div> {# End card #}
</div> {# End col-md-3 #}
</div> {# End row #}
</div> {# End container #}
<style>
/* Internal CSS Styles */
.log-window { margin-top: 1.5rem; }
.log-output {
background-color: #1e2228; border: 1px solid #444; border-radius: 4px; padding: 10px;
height: 250px; overflow-y: auto; font-family: 'Courier New', Courier, monospace;
font-size: 0.85rem; color: #e0e0e0; white-space: pre-wrap; word-wrap: break-word;
}
.log-output p { margin: 0; padding: 1px 0; border-bottom: 1px solid #333; line-height: 1.3; }
.log-output p:last-child { border-bottom: none; }
.log-output .log-timestamp { color: #777; margin-right: 8px; }
.log-output .text-muted.small { color: #888 !important; font-style: italic; border-bottom: none; }
/* Animation Status Text (from fire-animation.css or here) */
.minifire-status-text {
position: absolute; top: 8px; left: 0; width: 100%; text-align: center;
color: #f0f0f0; font-size: 0.85em; font-weight: bold; z-index: 60;
pointer-events: none; text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); line-height: 1.2;
}
/* Warning Text Style */
.import-warning-text {
color: #dc3545; /* Bootstrap danger color */
font-style: italic;
font-weight: bold;
text-align: center;
font-size: 0.9em;
line-height: 1.3;
}
/* Other general styles */
.text-muted { color: #ccc !important; font-size: 1.1rem; margin-top: 1rem; }
.table, .card { background-color: #2a2e34; color: #fff; }
.table th, .table td { border-color: #444; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-block; opacity: 1; }
#cache-spinner, #import-spinner { vertical-align: text-bottom; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check if HTMX is loaded (essential for button actions)
if (typeof htmx === 'undefined') {
console.error('HTMX is not loaded. Button functionality will be disabled.');
const searchErrorDiv = document.getElementById('search-error');
if(searchErrorDiv) {
searchErrorDiv.textContent = 'Error: Core page functionality failed to load. Refresh or contact support.';
searchErrorDiv.classList.remove('d-none');
}
// Disable interactive elements that rely on HTMX
document.getElementById('clear-cache-btn')?.setAttribute('disabled', 'true');
document.getElementById('submit-btn')?.setAttribute('disabled', 'true');
document.querySelector('input[name="search"]')?.setAttribute('disabled', 'true');
return; // Stop script execution
}
// Get references to frequently used DOM elements
const logOutput = document.getElementById('log-output');
const logLoadingIndicator = document.getElementById('log-loading-indicator');
const logAnimationWindow = document.getElementById('log-animation-window');
const animationStatusText = document.getElementById('animation-status-text');
const processWarningText = document.getElementById('process-warning-text'); // Get warning text element
let currentEventSource = null; // Variable to hold the active Server-Sent Events connection
// --- Helper Functions ---
// Appends a formatted log message to the log output window
function appendLogMessage(message) {
if (!logOutput) return;
const initialMsg = logOutput.querySelector('.text-muted.small'); // Find placeholder
if (initialMsg) initialMsg.remove(); // Remove placeholder on first message
const timestamp = new Date().toLocaleTimeString(); // Get current time for timestamp
const logEntry = document.createElement('p'); // Create paragraph for the log line
const messageText = String(message).replace(/^INFO:services:/, '').replace(/^INFO:app:/, '').trim(); // Clean message
logEntry.innerHTML = `<span class="log-timestamp">[${timestamp}]</span>`; // Add timestamp
logEntry.appendChild(document.createTextNode(messageText)); // Add log message text safely
// Style error/warning messages
if (messageText.startsWith("ERROR:") || messageText.startsWith("CRITICAL ERROR:")) {
logEntry.style.color = "#f8d7da"; logEntry.style.backgroundColor = "#49272a";
} else if (messageText.startsWith("WARNING:")) {
logEntry.style.color = "#fff3cd";
}
logOutput.appendChild(logEntry); // Add the log line to the window
logOutput.scrollTop = logOutput.scrollHeight; // Scroll to the bottom
}
// Clears the log window and resets the placeholder text
function clearLogWindow() {
if (logOutput) logOutput.innerHTML = '<p class="text-muted small">Waiting for logs...</p>';
}
// Shows the animation window and sets the status text
function showAnimation(statusText = '') {
if (logAnimationWindow) logAnimationWindow.style.display = 'flex'; // Use flex to utilize justify/align center for campfire
if (animationStatusText) animationStatusText.textContent = statusText; // Update status text
if (processWarningText) processWarningText.style.display = 'block'; // Show the warning text
}
// Hides the animation window and clears status/warning text
function hideAnimation() {
if (logAnimationWindow) logAnimationWindow.style.display = 'none';
if (animationStatusText) animationStatusText.textContent = '';
if (processWarningText) processWarningText.style.display = 'none'; // Hide the warning text
}
// --- Server-Sent Events (SSE) Setup Function ---
// Connects to the log stream and handles incoming messages
function setupLogStream(onDoneCallback) {
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close existing connection
clearLogWindow(); // Clear previous logs
if (logLoadingIndicator) logLoadingIndicator.style.display = 'block'; // Show "Connecting..."
// Note: showAnimation() is now called by the function initiating the action
try {
currentEventSource = new EventSource("{{ url_for('stream_import_logs') }}"); // Establish connection
// On successful connection opening
currentEventSource.onopen = function() {
console.log("SSE connection opened.");
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none'; // Hide "Connecting..."
// Animation should already be visible here if started correctly
};
// On receiving a message from the server
currentEventSource.onmessage = function(event) {
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none'; // Hide indicator if still visible
// Keep animation visible
// Check for the special [DONE] signal
if (event.data === '[DONE]') {
console.log("SSE stream finished ([DONE] received).");
hideAnimation(); // Hide animation and warning text
const lastLog = logOutput.lastElementChild?.textContent || ""; // Check last log for errors
const hadError = lastLog.includes("ERROR:") || lastLog.includes("CRITICAL ERROR:");
if (!hadError) { appendLogMessage("Operation complete."); } // Log completion if no errors seen
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close SSE
if (onDoneCallback) onDoneCallback(true, hadError); // Trigger callback (success=true)
} else {
appendLogMessage(event.data); // Log the received message
}
};
// On SSE connection error
currentEventSource.onerror = function(err) {
console.error('SSE connection error:', err);
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none';
hideAnimation(); // Hide animation and warning text on error
appendLogMessage("ERROR: Log streaming connection error or closed prematurely.");
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; }
if (onDoneCallback) onDoneCallback(false, true); // Trigger callback (success=false, error=true)
};
} catch (e) { // On error initializing EventSource
console.error('Failed to initialize SSE:', e);
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none';
hideAnimation(); // Hide animation and warning text on failure
appendLogMessage("ERROR: Unable to establish log stream: " + (e.message || 'Unknown Error'));
if (onDoneCallback) onDoneCallback(false, true); // Trigger callback (success=false, error=true)
}
}
// --- Page Load / Initial State Logic ---
// Check if the backend flagged that an initial fetch is happening
const isFetchingInitial = {{ is_fetching | tojson }};
if (isFetchingInitial) {
console.log("Initial fetch detected, setting up log stream.");
showAnimation('Refreshing cache...'); // Show animation and "Refreshing" text immediately
setupLogStream(function(success, streamHadError) {
console.log(`Initial fetch log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
hideAnimation(); // Hide animation when stream ends
// Backend handles the page update after its fetch finishes
});
}
// --- Event Listeners for Buttons ---
// Listener for "Clear & Refresh Cache" Button (HTMX Triggered)
const clearCacheBtn = document.getElementById('clear-cache-btn');
const cacheSpinner = document.getElementById('cache-spinner');
if (clearCacheBtn) {
// Before HTMX sends the request to start the cache refresh task
clearCacheBtn.addEventListener('htmx:beforeRequest', function(evt) {
console.log("HTMX Clear Cache request starting.");
clearCacheBtn.disabled = true; if(cacheSpinner) cacheSpinner.style.display = 'inline-block';
showAnimation('Refreshing cache...'); // Show animation and set text
// Setup the log stream, providing the callback for when SSE finishes
setupLogStream(function(success, streamHadError) {
// This runs ONLY when the SSE stream sends [DONE] or errors out
console.log(`Clear cache log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
hideAnimation(); // Hide animation and warning text
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none'; // Re-enable button/hide spinner
// Determine message and reload page
if (success && !streamHadError) { appendLogMessage("Cache refresh complete. Reloading page..."); }
else if (success && streamHadError) { appendLogMessage("Cache refresh finished with errors. Reloading page..."); }
else { appendLogMessage("ERROR: Cache refresh failed or log stream error. Reloading page..."); }
setTimeout(() => window.location.reload(), 750); // Reload after slight delay
});
});
// After HTMX gets the *initial* response from the API (should be 202 Accepted)
clearCacheBtn.addEventListener('htmx:afterRequest', function(evt) {
console.log('HTMX Clear Cache API response received. Status:', evt.detail.xhr.status);
if (evt.detail.failed || evt.detail.xhr.status >= 400) {
// The API call itself failed to start the task
hideAnimation(); // Hide animation/warning immediately
console.error("Failed to trigger cache refresh API:", evt.detail);
appendLogMessage(`ERROR: Could not start cache refresh (Server status: ${evt.detail.xhr.status})`);
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none'; // Reset button
// Close SSE stream if it was prematurely opened
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; appendLogMessage("Log stream closed."); }
} else if (evt.detail.xhr.status === 202) {
// Success: Task started in background
appendLogMessage("Cache refresh process started in background...");
// Keep animation and warning text visible, waiting for SSE logs
} else {
// Unexpected success status from API
hideAnimation(); // Hide animation/warning
appendLogMessage(`Warning: Unexpected server response ${evt.detail.xhr.status}. Refresh may not have started.`);
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none';
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close stream
}
});
}
// Listener for Import Button (HTMX Triggered)
const importForm = document.getElementById('import-ig-form');
const importSubmitBtn = document.getElementById('submit-btn');
const importSpinner = document.getElementById('import-spinner');
if (importForm && importSubmitBtn) {
// Before HTMX sends the import request
importSubmitBtn.addEventListener('htmx:beforeRequest', function(evt) {
console.log('HTMX import request starting.');
importSubmitBtn.disabled = true; if(importSpinner) importSpinner.style.display = 'inline-block';
showAnimation('Importing...'); // Show animation and set "Importing..." text
// Setup log stream, callback runs when SSE part finishes
setupLogStream(function(success, streamHadError) {
console.log(`Import log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
hideAnimation(); // Hide animation/warning when SSE finishes
// Note: Redirect or final status is handled in 'afterRequest' below
if (!success || streamHadError) { appendLogMessage("Warning: Log stream for import finished with errors or failed."); }
});
});
// After HTMX gets the response from the import API call
importSubmitBtn.addEventListener('htmx:afterRequest', function(evt) {
console.log('HTMX import request finished. Status:', evt.detail.xhr.status);
importSubmitBtn.disabled = false; if(importSpinner) importSpinner.style.display = 'none'; // Reset button/spinner
// Ensure animation/warning are hidden if the main request fails quickly
if (evt.detail.failed) hideAnimation();
// Check SSE state (though it should manage its own closure)
if (currentEventSource) console.warn("SSE connection might still be open after import HTMX request completed.");
// Handle failed import API request
if (evt.detail.failed) {
appendLogMessage(`ERROR: Import request failed (Status: ${evt.detail.xhr.status})`);
try { const errorData = JSON.parse(evt.detail.xhr.responseText); if (errorData?.message) appendLogMessage(` -> ${errorData.message}`); } catch(e) {}
} else {
// Handle successful import API request (parse JSON response for status/redirect)
try {
const data = JSON.parse(evt.detail.xhr.responseText);
console.log("Import response data:", data);
// --- REDIRECT/STATUS LOGIC ---
if (data?.status === 'success' && data.redirect) {
appendLogMessage("Import successful. Redirecting...");
setTimeout(() => { window.location.href = data.redirect; }, 1000); // Redirect after 1s
} else if (data?.status === 'warning') {
appendLogMessage(`Warning: ${data.message || 'Import partially successful.'}`); htmx.process(document.body); // Process flash messages if swapped
} else if (data?.status === 'error') {
appendLogMessage(`ERROR: ${data.message || 'Import failed.'}`); htmx.process(document.body); // Process flash messages if swapped
} else {
appendLogMessage("Import request completed (unexpected response format)."); htmx.process(document.body); // Process flash messages if swapped
}
// --- END REDIRECT/STATUS LOGIC ---
} catch (e) { // Handle JSON parsing error
console.error("Error parsing import response JSON:", e);
appendLogMessage("ERROR: Could not process server response after import.");
}
}
});
}
// --- HTMX Search Event Listener (No changes needed) ---
htmx.on('htmx:afterRequest', function(evt) {
if (evt.detail.requestConfig?.path?.includes('api/search-packages')) { /* ... existing search error handling ... */ }
});
htmx.on('htmx:afterSwap', function(evt) { console.log("HTMX content swapped for target:", evt.detail.target.id); });
// --- Page Unload Cleanup ---
// Close SSE connection if user navigates away or closes tab
window.addEventListener('beforeunload', () => {
if (currentEventSource) {
console.log("Page unloading, closing SSE connection.");
currentEventSource.close();
currentEventSource = null;
}
});
}); // End DOMContentLoaded
</script>
{% endblock %}