mirror of
https://github.com/exo-explore/exo.git
synced 2026-01-27 07:20:14 -05:00
Compare commits
1 Commits
rust-explo
...
optimize-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40cbecb5c4 |
@@ -198,8 +198,10 @@
|
|||||||
stroke: oklch(0.85 0.18 85 / 0.4);
|
stroke: oklch(0.85 0.18 85 / 0.4);
|
||||||
stroke-width: 1.5px;
|
stroke-width: 1.5px;
|
||||||
stroke-dasharray: 8, 8;
|
stroke-dasharray: 8, 8;
|
||||||
animation: flowAnimation 1s linear infinite;
|
animation: flowAnimation 1.5s linear infinite;
|
||||||
filter: drop-shadow(0 0 3px oklch(0.85 0.18 85 / 0.5));
|
filter: drop-shadow(0 0 3px oklch(0.85 0.18 85 / 0.5));
|
||||||
|
/* GPU optimization - hint to browser this element will animate */
|
||||||
|
will-change: stroke-dashoffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-link-active {
|
.graph-link-active {
|
||||||
@@ -208,6 +210,24 @@
|
|||||||
filter: drop-shadow(0 0 6px oklch(0.85 0.18 85 / 0.8));
|
filter: drop-shadow(0 0 6px oklch(0.85 0.18 85 / 0.8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduce motion for users who prefer it - also saves GPU */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.graph-link {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shooting-star {
|
||||||
|
animation: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pulse,
|
||||||
|
.cursor-blink,
|
||||||
|
.animate-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* CRT Screen effect for topology */
|
/* CRT Screen effect for topology */
|
||||||
.crt-screen {
|
.crt-screen {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -266,13 +286,15 @@ input:focus, textarea:focus {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shooting Stars Animation */
|
/* Shooting Stars Animation - GPU optimized */
|
||||||
.shooting-stars {
|
.shooting-stars {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
/* Only render when visible */
|
||||||
|
content-visibility: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shooting-star {
|
.shooting-star {
|
||||||
@@ -285,6 +307,9 @@ input:focus, textarea:focus {
|
|||||||
animation: shootingStar var(--duration, 3s) linear infinite;
|
animation: shootingStar var(--duration, 3s) linear infinite;
|
||||||
animation-delay: var(--delay, 0s);
|
animation-delay: var(--delay, 0s);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
/* GPU optimization */
|
||||||
|
will-change: transform, opacity;
|
||||||
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shooting-star::before {
|
.shooting-star::before {
|
||||||
@@ -320,3 +345,13 @@ input:focus, textarea:focus {
|
|||||||
transform: translate(400px, 400px);
|
transform: translate(400px, 400px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pause animations when page is hidden to save resources */
|
||||||
|
:root:has(body[data-page-hidden="true"]) {
|
||||||
|
.shooting-star,
|
||||||
|
.graph-link,
|
||||||
|
.status-pulse,
|
||||||
|
.cursor-blink {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
import { topologyData, isTopologyMinimized, debugMode } from '$lib/stores/app.svelte';
|
import { topologyData, isTopologyMinimized, debugMode } from '$lib/stores/app.svelte';
|
||||||
|
|
||||||
@@ -12,11 +12,35 @@ import { topologyData, isTopologyMinimized, debugMode } from '$lib/stores/app.sv
|
|||||||
|
|
||||||
let svgContainer: SVGSVGElement | undefined = $state();
|
let svgContainer: SVGSVGElement | undefined = $state();
|
||||||
let resizeObserver: ResizeObserver | undefined;
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
// Optimization: Track last render state to avoid unnecessary re-renders
|
||||||
|
let lastRenderHash = '';
|
||||||
|
let lastHighlightedNodesHash = '';
|
||||||
|
let lastDimensions = { width: 0, height: 0 };
|
||||||
|
let isRendering = false;
|
||||||
|
let pendingRender = false;
|
||||||
|
|
||||||
const isMinimized = $derived(isTopologyMinimized());
|
const isMinimized = $derived(isTopologyMinimized());
|
||||||
const data = $derived(topologyData());
|
const data = $derived(topologyData());
|
||||||
const debugEnabled = $derived(debugMode());
|
const debugEnabled = $derived(debugMode());
|
||||||
|
|
||||||
|
// Generate a hash of relevant data to detect actual changes
|
||||||
|
function generateDataHash(topologyData: typeof data, minimized: boolean, debug: boolean): string {
|
||||||
|
if (!topologyData) return 'null';
|
||||||
|
const nodes = topologyData.nodes || {};
|
||||||
|
const edges = topologyData.edges || [];
|
||||||
|
|
||||||
|
// Create a lightweight hash from key properties only
|
||||||
|
const nodeHashes = Object.entries(nodes).map(([id, n]) => {
|
||||||
|
const macmon = n.macmon_info;
|
||||||
|
return `${id}:${n.friendly_name || ''}:${macmon?.memory?.ram_usage || 0}:${macmon?.memory?.ram_total || 0}:${macmon?.temp?.gpu_temp_avg || 0}:${macmon?.gpu_usage?.[1] || 0}:${macmon?.sys_power || 0}`;
|
||||||
|
}).sort().join('|');
|
||||||
|
|
||||||
|
const edgeHash = edges.map(e => `${e.source}-${e.target}`).sort().join(',');
|
||||||
|
|
||||||
|
return `${nodeHashes}::${edgeHash}::${minimized}::${debug}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getNodeLabel(nodeId: string): string {
|
function getNodeLabel(nodeId: string): string {
|
||||||
const node = data?.nodes?.[nodeId];
|
const node = data?.nodes?.[nodeId];
|
||||||
return node?.friendly_name || nodeId.slice(0, 8);
|
return node?.friendly_name || nodeId.slice(0, 8);
|
||||||
@@ -932,16 +956,59 @@ function wrapLine(text: string, maxLen: number): string[] {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
// Throttled render function to prevent too-frequent updates
|
||||||
if (data) {
|
function scheduleRender() {
|
||||||
|
if (isRendering) {
|
||||||
|
pendingRender = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRendering = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
renderGraph();
|
renderGraph();
|
||||||
|
isRendering = false;
|
||||||
|
|
||||||
|
if (pendingRender) {
|
||||||
|
pendingRender = false;
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!data || !svgContainer) return;
|
||||||
|
|
||||||
|
// Generate hash of current state
|
||||||
|
const currentHash = generateDataHash(data, isMinimized, debugEnabled);
|
||||||
|
const highlightHash = Array.from(highlightedNodes).sort().join(',');
|
||||||
|
|
||||||
|
// Get current dimensions
|
||||||
|
const rect = svgContainer.getBoundingClientRect();
|
||||||
|
const dimensionsChanged = rect.width !== lastDimensions.width || rect.height !== lastDimensions.height;
|
||||||
|
|
||||||
|
// Only re-render if something actually changed
|
||||||
|
if (currentHash !== lastRenderHash || highlightHash !== lastHighlightedNodesHash || dimensionsChanged) {
|
||||||
|
lastRenderHash = currentHash;
|
||||||
|
lastHighlightedNodesHash = highlightHash;
|
||||||
|
lastDimensions = { width: rect.width, height: rect.height };
|
||||||
|
scheduleRender();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (svgContainer) {
|
if (svgContainer) {
|
||||||
|
// Use a debounced resize observer to prevent rapid re-renders
|
||||||
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
renderGraph();
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(() => {
|
||||||
|
const rect = svgContainer!.getBoundingClientRect();
|
||||||
|
if (rect.width !== lastDimensions.width || rect.height !== lastDimensions.height) {
|
||||||
|
lastDimensions = { width: rect.width, height: rect.height };
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
resizeObserver.observe(svgContainer);
|
resizeObserver.observe(svgContainer);
|
||||||
}
|
}
|
||||||
@@ -969,11 +1036,20 @@ function wrapLine(text: string, maxLen: number): string[] {
|
|||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
stroke-dasharray: 4, 4;
|
stroke-dasharray: 4, 4;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
animation: flowAnimation 0.75s linear infinite;
|
/* Slower animation = less GPU usage */
|
||||||
|
animation: flowAnimation 2s linear infinite;
|
||||||
|
/* GPU optimization */
|
||||||
|
will-change: stroke-dashoffset;
|
||||||
}
|
}
|
||||||
@keyframes flowAnimation {
|
@keyframes flowAnimation {
|
||||||
from { stroke-dashoffset: 0; }
|
from { stroke-dashoffset: 0; }
|
||||||
to { stroke-dashoffset: -10; }
|
to { stroke-dashoffset: -10; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Respect reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:global(.graph-link) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -297,6 +297,35 @@ function extractIpFromMultiaddr(ma?: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep comparison utility for preventing unnecessary state updates
|
||||||
|
function shallowEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a === null || b === null) return false;
|
||||||
|
if (typeof a !== 'object' || typeof b !== 'object') return false;
|
||||||
|
|
||||||
|
const aObj = a as Record<string, unknown>;
|
||||||
|
const bObj = b as Record<string, unknown>;
|
||||||
|
const aKeys = Object.keys(aObj);
|
||||||
|
const bKeys = Object.keys(bObj);
|
||||||
|
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (aObj[key] !== bObj[key]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faster JSON comparison for complex nested objects
|
||||||
|
function jsonEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AppStore {
|
class AppStore {
|
||||||
// Conversation state
|
// Conversation state
|
||||||
conversations = $state<Conversation[]>([]);
|
conversations = $state<Conversation[]>([]);
|
||||||
@@ -330,9 +359,18 @@ class AppStore {
|
|||||||
topologyOnlyMode = $state(false);
|
topologyOnlyMode = $state(false);
|
||||||
chatSidebarVisible = $state(true); // Shown by default
|
chatSidebarVisible = $state(true); // Shown by default
|
||||||
|
|
||||||
|
// Visibility state - used to pause polling when tab is hidden
|
||||||
|
private isPageVisible = true;
|
||||||
|
|
||||||
private fetchInterval: ReturnType<typeof setInterval> | null = null;
|
private fetchInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private previewsInterval: ReturnType<typeof setInterval> | null = null;
|
private previewsInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private lastConversationPersistTs = 0;
|
private lastConversationPersistTs = 0;
|
||||||
|
|
||||||
|
// Cache for comparison - prevents unnecessary reactivity
|
||||||
|
private lastTopologyJson = '';
|
||||||
|
private lastInstancesJson = '';
|
||||||
|
private lastRunnersJson = '';
|
||||||
|
private lastDownloadsJson = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -341,9 +379,26 @@ class AppStore {
|
|||||||
this.loadDebugModeFromStorage();
|
this.loadDebugModeFromStorage();
|
||||||
this.loadTopologyOnlyModeFromStorage();
|
this.loadTopologyOnlyModeFromStorage();
|
||||||
this.loadChatSidebarVisibleFromStorage();
|
this.loadChatSidebarVisibleFromStorage();
|
||||||
|
this.setupVisibilityListener();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for page visibility changes to pause polling when hidden
|
||||||
|
*/
|
||||||
|
private setupVisibilityListener() {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
this.isPageVisible = document.visibilityState === 'visible';
|
||||||
|
|
||||||
|
if (this.isPageVisible) {
|
||||||
|
// Resume polling when page becomes visible
|
||||||
|
this.fetchState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load conversations from localStorage
|
* Load conversations from localStorage
|
||||||
*/
|
*/
|
||||||
@@ -770,7 +825,9 @@ class AppStore {
|
|||||||
|
|
||||||
startPolling() {
|
startPolling() {
|
||||||
this.fetchState();
|
this.fetchState();
|
||||||
this.fetchInterval = setInterval(() => this.fetchState(), 1000);
|
// Poll every 2 seconds instead of 1 second - reduces CPU/GPU load by 50%
|
||||||
|
// Data comparison ensures we only update when something actually changes
|
||||||
|
this.fetchInterval = setInterval(() => this.fetchState(), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopPolling() {
|
stopPolling() {
|
||||||
@@ -782,6 +839,9 @@ class AppStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchState() {
|
async fetchState() {
|
||||||
|
// Skip polling when page is hidden to save resources
|
||||||
|
if (!this.isPageVisible) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/state');
|
const response = await fetch('/state');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -789,19 +849,44 @@ class AppStore {
|
|||||||
}
|
}
|
||||||
const data: RawStateResponse = await response.json();
|
const data: RawStateResponse = await response.json();
|
||||||
|
|
||||||
|
// Only update topology if it actually changed (prevents unnecessary D3 re-renders)
|
||||||
if (data.topology) {
|
if (data.topology) {
|
||||||
this.topologyData = transformTopology(data.topology, data.nodeProfiles);
|
const newTopology = transformTopology(data.topology, data.nodeProfiles);
|
||||||
|
const newTopologyJson = JSON.stringify(newTopology);
|
||||||
|
if (newTopologyJson !== this.lastTopologyJson) {
|
||||||
|
this.lastTopologyJson = newTopologyJson;
|
||||||
|
this.topologyData = newTopology;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update instances if changed
|
||||||
if (data.instances) {
|
if (data.instances) {
|
||||||
this.instances = data.instances;
|
const newInstancesJson = JSON.stringify(data.instances);
|
||||||
this.refreshConversationModelFromInstances();
|
if (newInstancesJson !== this.lastInstancesJson) {
|
||||||
|
this.lastInstancesJson = newInstancesJson;
|
||||||
|
this.instances = data.instances;
|
||||||
|
this.refreshConversationModelFromInstances();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update runners if changed
|
||||||
if (data.runners) {
|
if (data.runners) {
|
||||||
this.runners = data.runners;
|
const newRunnersJson = JSON.stringify(data.runners);
|
||||||
|
if (newRunnersJson !== this.lastRunnersJson) {
|
||||||
|
this.lastRunnersJson = newRunnersJson;
|
||||||
|
this.runners = data.runners;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update downloads if changed
|
||||||
if (data.downloads) {
|
if (data.downloads) {
|
||||||
this.downloads = data.downloads;
|
const newDownloadsJson = JSON.stringify(data.downloads);
|
||||||
|
if (newDownloadsJson !== this.lastDownloadsJson) {
|
||||||
|
this.lastDownloadsJson = newDownloadsJson;
|
||||||
|
this.downloads = data.downloads;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastUpdate = Date.now();
|
this.lastUpdate = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching state:', error);
|
console.error('Error fetching state:', error);
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
let isPageHidden = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
// Listen for visibility changes to pause animations when hidden
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
isPageHidden = document.visibilityState === 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -9,7 +27,7 @@
|
|||||||
<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
|
<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-background text-foreground">
|
<div class="min-h-screen bg-background text-foreground" data-page-hidden={isPageHidden}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -99,17 +99,35 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compute highlighted nodes from hovered instance or hovered preview
|
// Compute highlighted nodes from hovered instance or hovered preview
|
||||||
|
// Memoized to avoid creating new Sets on every render
|
||||||
|
let lastHighlightedNodesKey = '';
|
||||||
|
let cachedHighlightedNodes: Set<string> = new Set();
|
||||||
|
|
||||||
const highlightedNodes = $derived(() => {
|
const highlightedNodes = $derived(() => {
|
||||||
|
// Create a key for the current state to enable memoization
|
||||||
|
const previewKey = Array.from(hoveredPreviewNodes).sort().join(',');
|
||||||
|
const currentKey = `${hoveredInstanceId || 'null'}:${previewKey}`;
|
||||||
|
|
||||||
|
// Return cached value if nothing changed
|
||||||
|
if (currentKey === lastHighlightedNodesKey) {
|
||||||
|
return cachedHighlightedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastHighlightedNodesKey = currentKey;
|
||||||
|
|
||||||
// First check instance hover
|
// First check instance hover
|
||||||
if (hoveredInstanceId) {
|
if (hoveredInstanceId) {
|
||||||
const instanceWrapped = instanceData[hoveredInstanceId];
|
const instanceWrapped = instanceData[hoveredInstanceId];
|
||||||
return unwrapInstanceNodes(instanceWrapped);
|
cachedHighlightedNodes = unwrapInstanceNodes(instanceWrapped);
|
||||||
|
return cachedHighlightedNodes;
|
||||||
}
|
}
|
||||||
// Then check preview hover
|
// Then check preview hover
|
||||||
if (hoveredPreviewNodes.size > 0) {
|
if (hoveredPreviewNodes.size > 0) {
|
||||||
return hoveredPreviewNodes;
|
cachedHighlightedNodes = hoveredPreviewNodes;
|
||||||
|
return cachedHighlightedNodes;
|
||||||
}
|
}
|
||||||
return new Set<string>();
|
cachedHighlightedNodes = new Set<string>();
|
||||||
|
return cachedHighlightedNodes;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to estimate memory from model ID (mirrors ModelCard logic)
|
// Helper to estimate memory from model ID (mirrors ModelCard logic)
|
||||||
@@ -516,12 +534,13 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: Log downloads data when it changes
|
// Debug: Log downloads data when it changes (disabled in production for performance)
|
||||||
$effect(() => {
|
// Uncomment for debugging:
|
||||||
if (downloadsData && Object.keys(downloadsData).length > 0) {
|
// $effect(() => {
|
||||||
console.log('[Download Debug] Current downloads:', downloadsData);
|
// if (downloadsData && Object.keys(downloadsData).length > 0) {
|
||||||
}
|
// console.log('[Download Debug] Current downloads:', downloadsData);
|
||||||
});
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
// Helper to get download status for an instance
|
// Helper to get download status for an instance
|
||||||
function getInstanceDownloadStatus(instanceId: string, instanceWrapped: unknown): {
|
function getInstanceDownloadStatus(instanceId: string, instanceWrapped: unknown): {
|
||||||
|
|||||||
Reference in New Issue
Block a user