Files
OSIT-AE-App-Svelte/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte
2026-05-13 13:36:57 -04:00

601 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { ChevronDown, ChevronUp, Cpu, RefreshCw } from '@lucide/svelte';
/**
* launcher_background_sync.svelte
* Exhaustive Background Pre-Caching Engine with Loop Monitoring.
*/
import { onMount, onDestroy } from 'svelte';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import {
events_loc,
events_slct,
events_sess
} from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { db_events } from '$lib/ae_events/db_events';
import * as native from '$lib/electron/electron_relay';
const { cleanup_tmp_files } = native;
let { log_lvl = 1 } = $props();
let currently_syncing: string | null = $state(null);
let sync_results: Record<string, 'success' | 'error' | 'pending'> = $state({});
let sync_stats = $state({ total: 0, cached: 0, missing: 0 });
let last_heartbeat: string | null = $state(null);
function is_url_file(file_obj: any): boolean {
const filename = (file_obj?.filename ?? '') as string;
const extension = (file_obj?.extension ?? '').toLowerCase();
return (
filename.startsWith('https://') ||
filename.startsWith('http://') ||
extension === 'url'
);
}
// Loop Timings (Visible in UI)
// WHY: Session was originally 15s which combined with the SWR background-refresh
// pattern caused a continuous API call every 15s even on cache hits. 60s is still
// responsive for live conference use but dramatically reduces server load.
// Defaults here must match the onMount fallbacks below — keep in sync.
let loop_info = $state({
event: 90000,
device: 60000,
location: 60000,
session: 60000,
presentation: 120000,
presenter: 120000,
file_sync: 30000
});
// Timer Handles
let timer__event: any = $state(null);
let timer__device: any = $state(null);
let timer__location: any = $state(null);
let timer__session: any = $state(null);
let timer__presentation: any = $state(null);
let timer__presenter: any = $state(null);
let timer__file_sync: any = $state(null);
let is_syncing = false;
let show_monitor = $state(false);
onMount(async () => {
// ... (hydration logic preserved)
if ($ae_loc.is_native) {
try {
const info = await native.get_device_info();
if (info) {
$ae_loc.home_directory = info.home_directory;
$ae_loc.tmp_directory = info.tmp_directory;
// Also sync into native_device for redundancy
if (!$ae_loc.native_device) $ae_loc.native_device = {};
$ae_loc.native_device.home_directory = info.home_directory;
$ae_loc.native_device.tmp_directory = info.tmp_directory;
if (log_lvl)
console.log('Sync: Native OS metadata hydrated.', {
home: info.home_directory
});
}
} catch (err) {
console.error('Sync: Metadata hydration FAILED:', err);
}
}
const dev = $ae_loc.native_device || {};
const cfg = $events_loc.launcher.sync_intervals || {};
// Load timings from persistent config, with fallbacks to device config or defaults.
// Fallback values here must match the loop_info $state defaults above — keep in sync.
loop_info.event = cfg.event || dev.check_event_loop_period || 90000;
loop_info.device =
cfg.device || dev.check_event_device_loop_period || 60000;
loop_info.location =
cfg.location || dev.check_event_location_loop_period || 60000;
loop_info.session =
cfg.session || dev.check_event_session_loop_period || 60000;
loop_info.presentation =
cfg.presentation || dev.check_event_presentation_loop_period || 120000;
loop_info.presenter =
cfg.presenter || dev.check_event_presenter_loop_period || 120000;
loop_info.file_sync =
cfg.file_sync || dev.check_file_sync_loop_period || 30000;
// 1. Structural/Metadata Loops
timer__event = setInterval(() => refresh_event_data(), loop_info.event);
timer__device = setInterval(() => run_device_heartbeat(), loop_info.device);
timer__location = setInterval(
() => refresh_location_config(),
loop_info.location
);
// 2. Room Content Refresh Loops (API -> Dexie)
timer__session = setInterval(
() => refresh_session_data(),
loop_info.session
);
timer__presentation = setInterval(
() => refresh_presentation_data(),
loop_info.presentation
);
timer__presenter = setInterval(
() => refresh_presenter_data(),
loop_info.presenter
);
// 3. Native File Sync Loop (Dexie -> Disk)
timer__file_sync = setInterval(() => run_sync_cycle(), loop_info.file_sync);
// Immediate first run for metadata
refresh_event_data();
run_device_heartbeat();
refresh_location_config();
// Stagger initial data fetches
setTimeout(() => refresh_session_data(), 1000);
setTimeout(() => refresh_presentation_data(), 3000);
setTimeout(() => refresh_presenter_data(), 5000);
// Clean up stale .tmp files (interrupted downloads) on startup.
// Age threshold is user-configurable (cfg → General → Cache Maintenance), default 24h.
const cache_root = $ae_loc.local_file_cache_path;
if ($ae_loc.is_native && cache_root) {
const max_age_hours =
$events_loc.launcher.cleanup_tmp_max_age_hours ?? 24;
cleanup_tmp_files({
cache_root,
max_age_minutes: max_age_hours * 60
}).then((result) => {
if (log_lvl) console.log('Sync: .tmp cleanup complete.', result);
});
}
});
onDestroy(() => {
if (timer__event) clearInterval(timer__event);
if (timer__device) clearInterval(timer__device);
if (timer__location) clearInterval(timer__location);
if (timer__session) clearInterval(timer__session);
if (timer__presentation) clearInterval(timer__presentation);
if (timer__presenter) clearInterval(timer__presenter);
if (timer__file_sync) clearInterval(timer__file_sync);
});
// WHY: refresh_location_config() runs on a 60s timer. Without this effect,
// switching locations leaves the new location's file list empty until the
// next timer tick. Watching event_location_id here fires an immediate fetch
// whenever the operator selects a different room.
$effect(() => {
const location_id = $events_slct.event_location_id;
if (location_id) {
refresh_location_config();
}
});
/**
* API Refresh: Event
*/
async function refresh_event_data() {
if ($events_loc.launcher.sync_paused) return;
if (!$events_slct.event_id) return;
try {
await events_func.load_ae_obj_id__event({
api_cfg: $ae_api,
event_id: $events_slct.event_id,
inc_location_li: true,
enabled: 'enabled',
hidden: 'all',
try_cache: true,
log_lvl: 0
});
} catch (err) {}
}
/**
* API Refresh: Sessions in Room
*/
async function refresh_session_data() {
if ($events_loc.launcher.sync_paused) return;
const location_id = $events_slct.event_location_id;
if (!location_id) return;
try {
if (log_lvl > 1)
console.log(
`Sync: Refreshing sessions for location: ${location_id}`
);
await events_func.load_ae_obj_li__event_session({
api_cfg: $ae_api,
for_obj_type: 'event_location',
for_obj_id: location_id,
view: 'alt',
hidden: 'all', // Launcher is operator-only; fetch all sessions so the "All Sessions" toggle works
try_cache: true,
log_lvl: 0
});
} catch (err) {}
}
/**
* API Refresh: Presentations for Selected Session
*/
async function refresh_presentation_data() {
if ($events_loc.launcher.sync_paused) return;
const session_id = $events_slct.event_session_id;
if (!session_id) return;
try {
if (log_lvl > 1)
console.log(
`Sync: Refreshing presentations for session: ${session_id}`
);
await events_func.load_ae_obj_li__event_presentation({
api_cfg: $ae_api,
for_obj_type: 'event_session',
for_obj_id: session_id,
try_cache: true,
log_lvl: 0
});
} catch (err) {}
}
/**
* API Refresh: Presenters for Selected Session
*/
async function refresh_presenter_data() {
if ($events_loc.launcher.sync_paused) return;
const session_id = $events_slct.event_session_id;
if (!session_id) return;
try {
if (log_lvl > 1)
console.log(
`Sync: Refreshing presenters for session: ${session_id}`
);
await events_func.load_ae_obj_li__event_presenter({
api_cfg: $ae_api,
for_obj_type: 'event_session',
for_obj_id: session_id,
try_cache: true,
log_lvl: 0
});
} catch (err) {}
}
async function run_sync_cycle() {
if ($events_loc.launcher.sync_paused) return;
const location_id = $events_slct.event_location_id;
const cache_root = $ae_loc.local_file_cache_path;
const prefix_len = $ae_loc.native_device?.hash_prefix_length || 2;
if (!location_id || !cache_root || is_syncing || !$ae_loc.is_native) return;
is_syncing = true;
try {
const sessions = await db_events.session
.where('event_location_id')
.equals(location_id)
.toArray();
const session_ids = sessions.map((s) => s.event_session_id);
if (session_ids.length === 0) return;
const presentations = await db_events.presentation
.where('event_session_id')
.anyOf(session_ids)
.toArray();
const presentation_ids = presentations.map(
(p) => p.event_presentation_id
);
const presenters = await db_events.presenter
.where('event_session_id')
.anyOf(session_ids)
.toArray();
const presenter_ids = presenters.map((p) => p.event_presenter_id);
const all_for_ids = [
...session_ids,
...presentation_ids,
...presenter_ids,
$events_slct.event_id
];
const files = await db_events.file
.where('for_id')
.anyOf(all_for_ids)
.toArray();
const cacheable_files = files.filter((file_obj) => !is_url_file(file_obj));
sync_stats.total = cacheable_files.length;
let cached_count = 0;
let missing_count = 0;
for (const file_obj of cacheable_files) {
// Re-check pause flag each iteration — a sync cycle can run for many
// seconds if there are missing files, so we must honour a pause request
// mid-loop rather than waiting for the entire batch to finish.
if ($events_loc.launcher.sync_paused) break;
if (!file_obj.hash_sha256) continue;
if (sync_results[file_obj.event_file_id] === 'success') {
cached_count++;
continue;
}
const exists = await native.check_hash_file_cache({
cache_root,
hash: file_obj.hash_sha256,
hash_prefix_length: prefix_len,
verify_hash: true // Hardened check: Perform full SHA-256 verify if file exists
});
if (exists) {
sync_results[file_obj.event_file_id] = 'success';
cached_count++;
continue;
}
missing_count++;
currently_syncing = file_obj.filename;
$events_sess.launcher.sync_stats.currently_syncing =
currently_syncing;
// Use the PROVEN endpoint path from api.ts that is known to work in Default Mode.
const url = `${$ae_api.base_url}/v3/action/hosted_file/${file_obj.hosted_file_id}/download?return_file=true&filename=${encodeURIComponent(file_obj.filename)}&key=${$ae_api.account_id}`;
const result = await native.download_to_cache({
url,
cache_root,
hash: file_obj.hash_sha256,
api_key: $ae_api.api_secret_key,
account_id: $ae_api.account_id,
hash_prefix_length: prefix_len
});
if (result.success)
sync_results[file_obj.event_file_id] = 'success';
}
sync_stats.cached = cached_count;
sync_stats.missing = missing_count;
// Sync to shared store
$events_sess.launcher.sync_stats = {
total: sync_stats.total,
cached: sync_stats.cached,
missing: sync_stats.missing,
currently_syncing: null
};
} catch (err) {
console.error('Sync Engine Error:', err);
} finally {
currently_syncing = null;
$events_sess.launcher.sync_stats.currently_syncing = null;
is_syncing = false;
}
}
/**
* Device Heartbeat & Telemetry
* Gathers OS info and updates the device record in the cloud.
*/
async function run_device_heartbeat() {
const dev = $ae_loc.native_device;
// String-Only ID Vision: Prioritize semantic string IDs, then generic, then legacy random strings
const device_id =
dev?.event_device_id ||
dev?.id;
if (!device_id) {
// Only log warning if we are actually supposed to be in native mode
if (log_lvl && $ae_loc.is_native)
console.warn(
'Sync: Heartbeat skipped, no device_id found in $ae_loc.native_device.'
);
$events_sess.launcher.heartbeat_info.status = 'error';
return;
}
if (log_lvl > 1)
console.log(`Sync: Running heartbeat for device: ${device_id}`);
try {
let info = null;
if ($ae_loc.is_native) {
info = await native.get_device_info();
}
const update_payload: any = {
// Use standard SQL format YYYY-MM-DD HH:mm:ss for MySQL compatibility
heartbeat: ae_util.iso_datetime_formatter(
new Date(),
'datetime_iso'
)
};
if (info) {
update_payload.info_hostname = info.hostname;
// Safely handle IP list (bridge may return ip_addresses or networkInterfaces)
const ips = info.ip_addresses || [];
update_payload.info_ip_list = Array.isArray(ips)
? ips.join(', ')
: 'Unknown';
update_payload.meta_json = {
platform: info.platform,
release: info.release,
arch: info.arch,
cpus: info.cpus,
total_mem: Math.round(info.total_mem / (1024 * 1024)) + 'MB',
free_mem: Math.round(info.free_mem / (1024 * 1024)) + 'MB'
};
} else {
update_payload.info_hostname = 'Web Browser';
update_payload.notes = 'Heartbeat from non-native web session.';
}
await events_func.update_ae_obj__event_device({
api_cfg: $ae_api,
event_device_id: String(device_id),
data_kv: update_payload,
log_lvl: 0
});
last_heartbeat = new Date().toLocaleTimeString();
$events_sess.launcher.heartbeat_info = {
last_timestamp: last_heartbeat,
status: 'success'
};
if (log_lvl > 1) console.log('Sync: Device heartbeat SUCCESS.');
} catch (err) {
console.error('Sync: Device heartbeat FAILED:', err);
$events_sess.launcher.heartbeat_info.status = 'error';
}
}
/**
* Location Config Refresh
* Ensures we have latest room settings.
*/
async function refresh_location_config() {
if ($events_loc.launcher.sync_paused) return;
const location_id = $events_slct.event_location_id;
if (!location_id) return;
try {
// WHY: inc_file_li must be true so location-level files reach Dexie.
// Without it, lq__location_event_file_obj_li in the layout finds nothing and
// the location file list in the Launcher menu never renders.
await events_func.load_ae_obj_id__event_location({
api_cfg: $ae_api,
event_location_id: location_id,
inc_file_li: true,
try_cache: true,
log_lvl: 0
});
} catch (err) {
console.error('Sync: Location refresh FAILED:', err);
}
}
</script>
<!-- Monitor Overlay: only shown in Native/App mode.
Positioned bottom-left at bottom-20 to clear the debug π button (bottom-8 left-2)
and the sys bar (bottom-12 right-2). Panel grows upward from the status chip. -->
{#if $events_loc.launcher.app_mode === 'native' || $ae_loc.is_native}
<div
class="pointer-events-none fixed bottom-20 left-4 z-[9999] flex flex-col items-start gap-2">
{#if show_monitor}
<div
class="bg-surface-50/95 dark:bg-surface-900/95 text-surface-800 dark:text-surface-100 border-surface-200 dark:border-primary-700 pointer-events-auto min-w-52 rounded-lg border p-3 font-mono text-[10px] shadow-2xl backdrop-blur-sm">
<div
class="border-surface-200 dark:border-primary-700 mb-2 flex justify-between border-b pb-1">
<span
class="text-primary-600 dark:text-primary-400 font-bold"
>NATIVE SYNC MONITOR</span>
<button
type="button"
onclick={() => (show_monitor = false)}
class="text-error-500 hover:text-error-400 ml-2"
>×</button>
</div>
<div class="mb-2 grid grid-cols-2 gap-x-4 gap-y-1">
<span
class="text-primary-700 dark:text-primary-300 opacity-60"
>Room Status:</span>
<span class="text-right"
>{sync_stats.cached} / {sync_stats.total} Files</span>
<span
class="text-primary-700 dark:text-primary-300 opacity-60"
>Prefix Len:</span>
<span class="text-right"
>{$ae_loc.native_device?.hash_prefix_length || 2} chars</span>
<span
class="text-primary-700 dark:text-primary-300 opacity-60"
>Heartbeat:</span>
<span
class="text-right {last_heartbeat
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'}">
{last_heartbeat || 'Pending...'}
</span>
</div>
<div
class="border-surface-200 dark:border-surface-700 flex flex-col gap-1 border-t pt-2">
<div class="flex items-center justify-between">
<span class:text-primary-500={timer__event}
>Event Loop:</span>
<span>{loop_info.event / 1000}s</span>
</div>
<div class="flex items-center justify-between">
<span class:text-primary-500={timer__device}
>Device Loop:</span>
<span>{loop_info.device / 1000}s</span>
</div>
<div class="flex items-center justify-between">
<span class:text-primary-500={timer__location}
>Location Loop:</span>
<span>{loop_info.location / 1000}s</span>
</div>
<div class="flex items-center justify-between">
<span class:text-primary-500={timer__session}
>Session Loop:</span>
<span>{loop_info.session / 1000}s</span>
</div>
<div class="flex items-center justify-between">
<span class:text-primary-500={timer__presentation}
>Pres Loop:</span>
<span>{loop_info.presentation / 1000}s</span>
</div>
<div class="flex items-center justify-between">
<span class:text-primary-500={timer__presenter}
>Speaker Loop:</span>
<span>{loop_info.presenter / 1000}s</span>
</div>
</div>
</div>
{/if}
<!-- Compact always-visible status chip.
Syncing: animated primary border + pulse. Idle: subtle muted chip.
Replaces the old invisible secret button so staff can always see the monitor. -->
<button
type="button"
onclick={() => (show_monitor = !show_monitor)}
class="
pointer-events-auto flex items-center
gap-1.5 rounded-lg px-2
py-1.5 font-mono
text-[10px]
transition-all
{currently_syncing
? 'bg-primary-500/15 dark:bg-primary-500/25 border-primary-500 text-primary-700 dark:text-primary-300 animate-pulse border shadow-md'
: 'bg-surface-100/90 dark:bg-surface-800/80 border-surface-300 dark:border-surface-600 text-surface-600 dark:text-surface-300 border opacity-60 shadow-sm hover:opacity-100'}
"
title="Native Sync Monitor · {sync_stats.cached}/{sync_stats.total} files · click to {show_monitor
? 'close'
: 'open'}">
{#if currently_syncing}<RefreshCw
size="1em"
class="text-primary-500 animate-spin text-[9px]" />{:else}<Cpu
size="1em"
class="text-[9px] opacity-50" />{/if}
{#if currently_syncing}
<span class="max-w-40 truncate text-left"
>{currently_syncing}</span>
{:else}
<span>Native Sync</span>
<span class="ml-0.5 opacity-50"
>{sync_stats.cached}/{sync_stats.total}</span>
{/if}
{#if show_monitor}<ChevronDown
size="1em"
class="ml-0.5 text-[7px] opacity-40" />{:else}<ChevronUp
size="1em"
class="ml-0.5 text-[7px] opacity-40" />{/if}
</button>
</div>
{/if}