601 lines
22 KiB
Svelte
601 lines
22 KiB
Svelte
<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}
|