This commit is contained in:
Timothy Jaeryang Baek
2025-12-31 20:23:32 +04:00
parent 6c203fc7df
commit 71ca25c8ac
4 changed files with 425 additions and 197 deletions
+2 -4
View File
@@ -810,10 +810,8 @@ class ChatTable:
query = db.query(Chat).filter_by(user_id=user_id) query = db.query(Chat).filter_by(user_id=user_id)
if filter: if filter:
if filter.get("start_time"): if filter.get("updated_at"):
query = query.filter(Chat.created_at >= filter.get("start_time")) query = query.filter(Chat.updated_at > filter.get("updated_at"))
if filter.get("end_time"):
query = query.filter(Chat.created_at <= filter.get("end_time"))
order_by = filter.get("order_by") order_by = filter.get("order_by")
direction = filter.get("direction") direction = filter.get("direction")
+81 -22
View File
@@ -380,28 +380,35 @@ def calculate_chat_stats(
return chat_stats_export_list, result.total return chat_stats_export_list, result.total
async def generate_chat_stats_jsonl_generator( def generate_chat_stats_jsonl_generator(user_id, filter):
user_id, filter, db: Optional[Session] = None """
): Synchronous generator for streaming chat stats export.
NOTE: We intentionally do NOT pass a shared db session here. Instead, we let
each batch create its own short-lived session via get_db_context(None).
This is critical for SQLite in low-resource environments because:
1. SQLite uses file-level locking
2. Holding a session open for the entire streaming duration blocks other requests
3. Short-lived sessions release locks between batches, allowing other operations
"""
skip = 0 skip = 0
limit = CHAT_EXPORT_PAGE_ITEM_COUNT limit = CHAT_EXPORT_PAGE_ITEM_COUNT
while True: while True:
# Use asyncio.to_thread to make the blocking DB call non-blocking # Each batch gets its own session that closes after the query
result = await asyncio.to_thread( result = Chats.get_chats_by_user_id(
Chats.get_chats_by_user_id,
user_id, user_id,
filter=filter, filter=filter,
skip=skip, skip=skip,
limit=limit, limit=limit,
db=db, db=None, # Let get_db_context create a fresh session per batch
) )
if not result.items: if not result.items:
break break
for chat in result.items: for chat in result.items:
try: try:
chat_stat = await asyncio.to_thread(_process_chat_for_export, chat) chat_stat = _process_chat_for_export(chat)
if chat_stat: if chat_stat:
yield chat_stat.model_dump_json() + "\n" yield chat_stat.model_dump_json() + "\n"
except Exception as e: except Exception as e:
@@ -413,9 +420,7 @@ async def generate_chat_stats_jsonl_generator(
@router.get("/stats/export", response_model=ChatStatsExportList) @router.get("/stats/export", response_model=ChatStatsExportList)
async def export_chat_stats( async def export_chat_stats(
request: Request, request: Request,
chat_id: Optional[str] = None, updated_at: Optional[int] = None,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
stream: bool = False, stream: bool = False,
user=Depends(get_verified_user), user=Depends(get_verified_user),
@@ -432,21 +437,14 @@ async def export_chat_stats(
try: try:
# Fetch chats with date filtering # Fetch chats with date filtering
filter = {"order_by": "created_at", "direction": "asc"} filter = {"order_by": "updated_at", "direction": "asc"}
if chat_id: if updated_at:
chat = Chats.get_chat_by_id(chat_id, db=db) filter["updated_at"] = updated_at
if chat:
filter["start_time"] = chat.created_at
if start_time:
filter["start_time"] = start_time
if end_time:
filter["end_time"] = end_time
if stream: if stream:
return StreamingResponse( return StreamingResponse(
generate_chat_stats_jsonl_generator(user.id, filter, db=db), generate_chat_stats_jsonl_generator(user.id, filter),
media_type="application/x-ndjson", media_type="application/x-ndjson",
headers={ headers={
"Content-Disposition": f"attachment; filename=chat-stats-export-{user.id}.jsonl" "Content-Disposition": f"attachment; filename=chat-stats-export-{user.id}.jsonl"
@@ -471,6 +469,67 @@ async def export_chat_stats(
) )
############################
# GetSingleChatStatsExport
############################
@router.get("/stats/export/{chat_id}", response_model=Optional[ChatStatsExport])
async def export_single_chat_stats(
request: Request,
chat_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""
Export stats for exactly one chat by ID.
Returns ChatStatsExport for the specified chat.
"""
# Check if the user has permission to share/export chats
if (user.role != "admin") and (
not request.app.state.config.ENABLE_COMMUNITY_SHARING
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
try:
chat = Chats.get_chat_by_id(chat_id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Verify the chat belongs to the user (unless admin)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Process the chat for export
chat_stats = await asyncio.to_thread(_process_chat_for_export, chat)
if not chat_stats:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to process chat stats",
)
return chat_stats
except HTTPException:
raise
except Exception as e:
log.debug(f"Error exporting single chat stats: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
@router.delete("/", response_model=bool) @router.delete("/", response_model=bool)
async def delete_all_user_chats( async def delete_all_user_chats(
request: Request, request: Request,
+33 -6
View File
@@ -1210,19 +1210,46 @@ export const exportChatStats = async (token: string, page: number = 1, params: o
return res; return res;
}; };
export const exportSingleChatStats = async (token: string, chatId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export/${chatId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadChatStats = async ( export const downloadChatStats = async (
token: string = '', token: string = '',
chat_id: string | null = null, updated_at: number | null = null
start_time: number | null = null,
end_time: number | null = null
): Promise<[Response | null, AbortController]> => { ): Promise<[Response | null, AbortController]> => {
const controller = new AbortController(); const controller = new AbortController();
let error = null; let error = null;
let url = `${WEBUI_API_BASE_URL}/chats/stats/export?stream=true`; let url = `${WEBUI_API_BASE_URL}/chats/stats/export?stream=true`;
if (chat_id) url += `&chat_id=${chat_id}`; if (updated_at) url += `&updated_at=${updated_at}`;
if (start_time) url += `&start_time=${start_time}`;
if (end_time) url += `&end_time=${end_time}`;
const res = await fetch(url, { const res = await fetch(url, {
signal: controller.signal, signal: controller.signal,
@@ -1,15 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Confetti } from 'svelte-confetti'; import { Confetti } from 'svelte-confetti';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte'; import { getContext, onMount, onDestroy, tick } from 'svelte';
import { exportChatStats, downloadChatStats } from '$lib/apis/chats'; import { exportChatStats, exportSingleChatStats, downloadChatStats } from '$lib/apis/chats';
import { getVersion } from '$lib/apis'; import { getVersion } from '$lib/apis';
import Check from '$lib/components/icons/Check.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
@@ -18,70 +16,167 @@
export let show = false; export let show = false;
export let eventData = null; export let eventData = null;
let syncing = false; // Listen for verify:chat messages from opener
let downloading = false; const handleMessage = async (event: MessageEvent) => {
let downloadController = null; // Community sends: { type: 'verify:chat', data: { id: ... } }
const chatId = event.data?.data?.id ?? event.data?.id;
const cancelDownload = () => { if (event.data?.type === 'verify:chat' && chatId) {
if (downloadController) { try {
downloadController.abort(); const res = await exportSingleChatStats(localStorage.token, chatId);
downloading = false; if (res && window.opener) {
syncing = false; window.opener.postMessage(
downloadController = null; {
type: 'verify:chat:response',
data: res,
chatId: chatId,
requestId: event.data.requestId ?? null
},
'*'
);
}
} catch (err: any) {
console.error('Failed to verify chat:', err);
if (window.opener) {
window.opener.postMessage(
{
type: 'verify:chat:error',
error: err?.detail || err?.message || 'Failed to verify chat',
chatId: chatId,
requestId: event.data.requestId ?? null
},
'*'
);
}
}
} }
}; };
onMount(() => {
window.addEventListener('message', handleMessage);
});
onDestroy(() => {
window.removeEventListener('message', handleMessage);
});
// State
let syncing = false;
let downloading = false;
let completed = false; let completed = false;
let error = false;
let errorMessage = '';
// Progress tracking
let processedItemsCount = 0; let processedItemsCount = 0;
let total = 0; let total = 0;
// Download controller for cancellation
let downloadController: AbortController | null = null;
// Sync mode: 'incremental' or 'full'
let syncMode: 'incremental' | 'full' = 'incremental';
// Reactive progress percentage
$: progressPercent = total > 0 ? Math.min(Math.round((processedItemsCount / total) * 100), 100) : 0;
// Helper to send postMessage to opener
const postToOpener = (message: object) => {
if (window.opener) {
window.opener.postMessage(
{ ...message, requestId: eventData?.requestId ?? null },
'*'
);
}
};
// Reset all state
const resetState = () => {
syncing = false;
downloading = false;
completed = false;
error = false;
errorMessage = '';
processedItemsCount = 0;
total = 0;
downloadController = null;
};
// Handle error state
const handleError = (message: string) => {
console.error('Sync error:', message);
errorMessage = message;
error = true;
syncing = false;
downloading = false;
postToOpener({ type: 'sync:error', error: message });
};
// Cancel ongoing operations
const cancelOperation = () => {
if (downloadController) {
downloadController.abort();
downloadController = null;
}
syncing = false;
downloading = false;
};
// Sync stats to opener window
const syncStats = async () => { const syncStats = async () => {
if (window.opener) { if (window.opener) {
window.opener.focus(); window.opener.focus();
window.opener.postMessage(
{ type: 'sync:start', requestId: eventData?.requestId ?? null },
'*'
);
}
const res = await getVersion(localStorage.token).catch(() => {
return null;
});
if (res) {
window.opener.postMessage(
{ type: 'sync:version', data: res, requestId: eventData?.requestId ?? null },
'*'
);
} }
postToOpener({ type: 'sync:start' });
syncing = true; syncing = true;
error = false;
errorMessage = '';
processedItemsCount = 0; processedItemsCount = 0;
total = 0; total = 0;
let page = 1;
let allItemsLoaded = false; try {
while (!allItemsLoaded) { // Get version info
const res = await exportChatStats( const versionRes = await getVersion(localStorage.token).catch((err) => {
localStorage.token, console.error('Failed to get version:', err);
page,
eventData?.searchParams ?? {}
).catch(() => {
return null; return null;
}); });
if (res) { if (versionRes) {
postToOpener({ type: 'sync:version', data: versionRes });
}
// Paginate through stats
let page = 1;
let allItemsLoaded = false;
while (!allItemsLoaded) {
// Build search params, include updated_at for incremental mode
const searchParams = { ...(eventData?.searchParams ?? {}) };
if (syncMode === 'incremental' && eventData?.lastSyncedChatUpdatedAt) {
searchParams.updated_at = eventData.lastSyncedChatUpdatedAt;
}
const res = await exportChatStats(
localStorage.token,
page,
searchParams
).catch((err) => {
throw new Error(err?.detail || err?.message || 'Failed to export chat stats');
});
if (!res) {
throw new Error('Failed to fetch stats data');
}
processedItemsCount += res.items.length; processedItemsCount += res.items.length;
total = res.total; total = res.total;
if (window.opener) { // Allow UI to repaint
if (res.items.length > 0) { await tick();
window.opener.postMessage(
{ type: 'sync:stats:chats', data: res, requestId: eventData?.requestId ?? null }, if (window.opener && res.items.length > 0) {
'*' postToOpener({ type: 'sync:stats:chats', data: res });
);
}
} else {
console.log('No opener found to send stats back to.');
} }
if (processedItemsCount >= total || res.items.length === 0) { if (processedItemsCount >= total || res.items.length === 0) {
@@ -89,150 +184,180 @@
} else { } else {
page += 1; page += 1;
} }
} else {
allItemsLoaded = true;
} }
}
// send sync complete message // Success
if (window.opener) { postToOpener({ type: 'sync:complete' });
window.opener.postMessage( syncing = false;
{ type: 'sync:complete', requestId: eventData?.requestId ?? null }, completed = true;
'*' } catch (err: any) {
); handleError(err?.message || 'An unexpected error occurred');
} }
syncing = false;
completed = true;
}; };
// Download stats as JSON file
const downloadHandler = async () => { const downloadHandler = async () => {
if (downloading) { if (downloading) {
cancelDownload(); cancelOperation();
return; return;
} }
downloading = true; downloading = true;
syncing = true; syncing = true;
error = false;
// Get total count first errorMessage = '';
const _res = await exportChatStats(localStorage.token, 1, eventData?.searchParams ?? {}).catch(
() => {
return null;
}
);
if (_res) {
total = _res.total;
}
processedItemsCount = 0; processedItemsCount = 0;
const resVersion = await getVersion(localStorage.token).catch(() => { total = 0;
return null;
});
const version = resVersion ? resVersion.version : '0.0.0'; try {
const filename = `open-webui-stats-${version}-${Date.now()}.json`; // Get total count first (no filters for download - get all)
const initialRes = await exportChatStats(
localStorage.token,
1,
{}
).catch(() => null);
const searchParams = eventData?.searchParams ?? {}; if (initialRes?.total) {
const [res, controller] = await downloadChatStats( total = initialRes.total;
localStorage.token, }
searchParams.chat_id,
searchParams.start_time, // Allow UI to show the total
searchParams.end_time await tick();
).catch((error) => {
toast.error(error?.detail || $i18n.t('An error occurred while downloading your stats.')); // Get version for filename
return [null, null]; const versionRes = await getVersion(localStorage.token).catch(() => null);
}); const version = versionRes?.version ?? '0.0.0';
const filename = `open-webui-stats-${version}-${Date.now()}.json`;
// Start streaming download
const searchParams = eventData?.searchParams ?? {};
const [res, controller] = await downloadChatStats(
localStorage.token,
searchParams.updated_at
).catch((err) => {
throw new Error(err?.detail || 'Failed to connect to the server. Please check your connection.');
});
if (!res) {
throw new Error('Failed to start download. The server may be unavailable.');
}
if (res) {
downloadController = controller; downloadController = controller;
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const items = []; const items: any[] = [];
let buffer = ''; let buffer = '';
try { while (true) {
while (true) { const { done, value } = await reader.read();
const { done, value } = await reader.read(); if (done) break;
if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
if (line.trim() !== '') { if (line.trim() !== '') {
try { try {
items.push(JSON.parse(line)); items.push(JSON.parse(line));
processedItemsCount++; processedItemsCount += 1;
} catch (e) { } catch (e) {
console.error('Error parsing line', e); console.error('Error parsing line:', e);
}
} }
} }
} }
if (buffer.trim() !== '') { // Allow UI to repaint after each chunk
try { await tick();
items.push(JSON.parse(buffer));
processedItemsCount++;
} catch (e) {
console.error('Error parsing buffer', e);
}
}
if (downloading) {
const blob = new Blob([JSON.stringify(items)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
} catch (e) {
console.error('Download error:', e);
} finally {
downloading = false;
syncing = false;
downloadController = null;
} }
} else {
// Process remaining buffer
if (buffer.trim() !== '') {
try {
items.push(JSON.parse(buffer));
processedItemsCount += 1;
} catch (e) {
console.error('Error parsing buffer:', e);
}
}
// Trigger download if not cancelled
if (downloading) {
const blob = new Blob([JSON.stringify(items)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
} catch (err: any) {
// Don't show error if user cancelled the download
if (err?.name === 'AbortError' || err?.message?.includes('aborted')) {
// User cancelled - just reset state silently
} else {
handleError(err?.message || 'Download failed. Please try again.');
toast.error(errorMessage);
}
} finally {
downloading = false; downloading = false;
syncing = false; syncing = false;
downloadController = null; downloadController = null;
} }
}; };
// Close modal and reset state
const closeModal = () => {
show = false;
resetState();
};
</script> </script>
<Modal bind:show size="md"> <Modal bind:show size="md">
<div class="w-full"> <div class="w-full">
{#if completed} {#if completed}
<div class="flex flex-col items-center justify-center px-6 py-10"> <div class="px-5.5 py-5">
<div class="mb-1 text-xl font-medium">{$i18n.t('Sync Complete!')}</div>
<div class="mb-3 text-xs text-gray-500">
{$i18n.t('Your usage stats have been successfully synced.')}
</div>
<Confetti x={[-0.5, 0.5]} y={[0.25, 1]} /> <Confetti x={[-0.5, 0.5]} y={[0.25, 1]} />
<div class="rounded-full bg-green-100 dark:bg-green-900/30 p-3 mb-4"> <div class="flex justify-end">
<Check className="size-8 text-green-600 dark:text-green-400" /> <button
class="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-sm text-white transition hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100"
on:click={closeModal}
>
{$i18n.t('Done')}
</button>
</div>
</div>
{:else if error}
<div class="px-5.5 py-5">
<div class="mb-1 text-xl font-medium">{$i18n.t('Sync Failed')}</div>
<div class="mb-3 text-xs text-gray-500">
{errorMessage || $i18n.t('There was an error syncing your stats. Please try again.')}
</div> </div>
<div class="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100"> <div class="flex justify-end">
{$i18n.t('Sync Complete!')} <button
class="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-sm text-white transition hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100"
on:click={() => {
error = false;
errorMessage = '';
}}
>
{$i18n.t('Try Again')}
</button>
</div> </div>
<div class="text-gray-500 dark:text-gray-400 text-center mb-6 max-w-sm text-xs">
{$i18n.t('Your usage stats have been successfully synced with the Open WebUI Community.')}
</div>
<button
class="px-6 py-1.5 rounded-full text-sm font-medium bg-gray-900 hover:bg-gray-800 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 transition-colors"
on:click={() => (show = false)}
>
{$i18n.t('Done')}
</button>
</div> </div>
{:else} {:else}
<div class=" flex justify-between px-5 pt-4 pb-0.5"> <div class="flex justify-between px-5 pt-4 pb-0.5">
<div class=" text-lg font-medium self-center">{$i18n.t('Sync Usage Stats')}</div> <div class="text-lg font-medium self-center">{$i18n.t('Sync Usage Stats')}</div>
<button <button
class="self-center" class="self-center"
on:click={() => { on:click={() => {
@@ -277,19 +402,46 @@
</ul> </ul>
</div> </div>
{#if eventData?.lastSyncedChatUpdatedAt}
<div class="mt-3">
<Tooltip content={$i18n.t('Syncs only chats with updates after your last sync timestamp. Disable to re-sync all chats.')} placement="top-start">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input
type="checkbox"
checked={syncMode === 'incremental'}
on:change={(e) => syncMode = e.target.checked ? 'incremental' : 'full'}
disabled={syncing}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600"
/>
<span class="text-gray-700 dark:text-gray-300">{$i18n.t('Only sync new/updated chats')}</span>
</label>
</Tooltip>
</div>
{/if}
{#if syncing} {#if syncing}
<div class="mt-3 mx-1.5"> <div class="mt-3 mx-1.5">
<div class="text-xs text-gray-500 mb-1 flex justify-between"> <div class="text-xs text-gray-500 mb-1 flex justify-between">
<div> <div>
{downloading ? $i18n.t('Downloading stats...') : $i18n.t('Syncing stats...')} {downloading ? $i18n.t('Downloading stats...') : $i18n.t('Syncing stats...')}
</div> </div>
<div>{Math.round((processedItemsCount / total) * 100) || 0}%</div> <div>
{#if total > 0}
{processedItemsCount}/{total}
{:else}
{processedItemsCount}
{/if}
</div>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700"> <div class="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700 overflow-hidden">
<div {#if total > 0}
class="bg-gray-900 dark:bg-gray-100 h-1.5 rounded-full transition-all duration-300" <div
style="width: {(processedItemsCount / total) * 100}%" class="bg-gray-900 dark:bg-gray-100 h-1.5 rounded-full transition-all duration-300"
></div> style="width: {progressPercent}%"
></div>
{:else}
<div class="bg-gray-900 dark:bg-gray-100 h-1.5 w-1/3 rounded-full animate-pulse"></div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
@@ -297,11 +449,10 @@
<div class="mt-5 flex justify-between items-center gap-2"> <div class="mt-5 flex justify-between items-center gap-2">
<div class="text-xs text-gray-400 text-center mr-auto"> <div class="text-xs text-gray-400 text-center mr-auto">
<button <button
class=" hover:underline px-2" class="hover:underline px-2"
type="button" type="button"
on:click={async () => { on:click={downloadHandler}
downloadHandler(); disabled={syncing && !downloading}
}}
> >
{downloading ? $i18n.t('Stop Download') : $i18n.t('Download as JSON')} {downloading ? $i18n.t('Stop Download') : $i18n.t('Download as JSON')}
</button> </button>
@@ -311,12 +462,7 @@
class="px-4 py-2 rounded-full text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 transition disabled:cursor-not-allowed" class="px-4 py-2 rounded-full text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 transition disabled:cursor-not-allowed"
on:click={() => { on:click={() => {
if (syncing) { if (syncing) {
cancelDownload(); cancelOperation();
if (downloading) {
cancelDownload();
} else {
syncing = false;
}
} else { } else {
show = false; show = false;
} }
@@ -327,9 +473,7 @@
<button <button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition-colors rounded-full" class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition-colors rounded-full"
on:click={() => { on:click={syncStats}
syncStats();
}}
disabled={syncing} disabled={syncing}
> >
{#if syncing && !downloading} {#if syncing && !downloading}