feat(accounts): 自动刷新改为ETag增量同步并优化单账号更新体验

- 前端自动刷新改为 ETag/304 增量合并,减少全量重刷

- 单账号更新后增加静默窗口,避免刚更新即被自动刷新覆盖

- 列表筛选移除时改为待同步提示,不再立即触发全量补页

- 后端账号列表支持 If-None-Match,命中返回 304

- 单账号接口统一补充运行时容量字段并暴露 ETag 头
This commit is contained in:
yangjianbo
2026-02-14 13:22:51 +08:00
parent 40d110efe4
commit 06b0f62e79
6 changed files with 356 additions and 33 deletions

View File

@@ -2,8 +2,13 @@
package admin package admin
import ( import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -143,6 +148,44 @@ type AccountWithConcurrency struct {
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数 ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
} }
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: dto.AccountFromService(account),
CurrentConcurrency: 0,
}
if account == nil {
return item
}
if h.concurrencyService != nil {
if counts, err := h.concurrencyService.GetAccountConcurrencyBatch(ctx, []int64{account.ID}); err == nil {
item.CurrentConcurrency = counts[account.ID]
}
}
if account.IsAnthropicOAuthOrSetupToken() {
if h.accountUsageService != nil && account.GetWindowCostLimit() > 0 {
startTime := account.GetCurrentWindowStartTime()
if stats, err := h.accountUsageService.GetAccountWindowStats(ctx, account.ID, startTime); err == nil && stats != nil {
cost := stats.StandardCost
item.CurrentWindowCost = &cost
}
}
if h.sessionLimitCache != nil && account.GetMaxSessions() > 0 {
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
idleTimeouts := map[int64]time.Duration{account.ID: idleTimeout}
if sessions, err := h.sessionLimitCache.GetActiveSessionCountBatch(ctx, []int64{account.ID}, idleTimeouts); err == nil {
if count, ok := sessions[account.ID]; ok {
item.ActiveSessions = &count
}
}
}
}
return item
}
// List handles listing all accounts with pagination // List handles listing all accounts with pagination
// GET /api/v1/admin/accounts // GET /api/v1/admin/accounts
func (h *AccountHandler) List(c *gin.Context) { func (h *AccountHandler) List(c *gin.Context) {
@@ -258,9 +301,71 @@ func (h *AccountHandler) List(c *gin.Context) {
result[i] = item result[i] = item
} }
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Paginated(c, result, total, page, pageSize) response.Paginated(c, result, total, page, pageSize)
} }
func buildAccountsListETag(
items []AccountWithConcurrency,
total int64,
page, pageSize int,
platform, accountType, status, search string,
) string {
payload := struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Items []AccountWithConcurrency `json:"items"`
}{
Total: total,
Page: page,
PageSize: pageSize,
Platform: platform,
AccountType: accountType,
Status: status,
Search: search,
Items: items,
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return "\"" + hex.EncodeToString(sum[:]) + "\""
}
func ifNoneMatchMatched(ifNoneMatch, etag string) bool {
if etag == "" || ifNoneMatch == "" {
return false
}
for _, token := range strings.Split(ifNoneMatch, ",") {
candidate := strings.TrimSpace(token)
if candidate == "*" {
return true
}
if candidate == etag {
return true
}
if strings.HasPrefix(candidate, "W/") && strings.TrimPrefix(candidate, "W/") == etag {
return true
}
}
return false
}
// GetByID handles getting an account by ID // GetByID handles getting an account by ID
// GET /api/v1/admin/accounts/:id // GET /api/v1/admin/accounts/:id
func (h *AccountHandler) GetByID(c *gin.Context) { func (h *AccountHandler) GetByID(c *gin.Context) {
@@ -276,7 +381,7 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
return return
} }
response.Success(c, dto.AccountFromService(account)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
} }
// Create handles creating a new account // Create handles creating a new account
@@ -334,7 +439,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
return return
} }
response.Success(c, dto.AccountFromService(account)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
} }
// Update handles updating an account // Update handles updating an account
@@ -398,7 +503,7 @@ func (h *AccountHandler) Update(c *gin.Context) {
return return
} }
response.Success(c, dto.AccountFromService(account)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
} }
// Delete handles deleting an account // Delete handles deleting an account
@@ -656,7 +761,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
} }
} }
response.Success(c, dto.AccountFromService(updatedAccount)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount))
} }
// GetStats handles getting account statistics // GetStats handles getting account statistics
@@ -714,7 +819,7 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
} }
} }
response.Success(c, dto.AccountFromService(account)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
} }
// BatchCreate handles batch creating accounts // BatchCreate handles batch creating accounts
@@ -1112,7 +1217,7 @@ func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
return return
} }
response.Success(c, dto.AccountFromService(account)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
} }
// GetTempUnschedulable handles getting temporary unschedulable status // GetTempUnschedulable handles getting temporary unschedulable status
@@ -1202,7 +1307,7 @@ func (h *AccountHandler) SetSchedulable(c *gin.Context) {
return return
} }
response.Success(c, dto.AccountFromService(account)) response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
} }
// GetAvailableModels handles getting available models for an account // GetAvailableModels handles getting available models for an account

View File

@@ -70,6 +70,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
} }
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag")
c.Writer.Header().Set("Access-Control-Max-Age", "86400") c.Writer.Header().Set("Access-Control-Max-Age", "86400")
} }

View File

@@ -49,6 +49,58 @@ export async function list(
return data return data
} }
export interface AccountListWithEtagResult {
notModified: boolean
etag: string | null
data: PaginatedResponse<Account> | null
}
export async function listWithEtag(
page: number = 1,
pageSize: number = 20,
filters?: {
platform?: string
type?: string
status?: string
search?: string
},
options?: {
signal?: AbortSignal
etag?: string | null
}
): Promise<AccountListWithEtagResult> {
const headers: Record<string, string> = {}
if (options?.etag) {
headers['If-None-Match'] = options.etag
}
const response = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
params: {
page,
page_size: pageSize,
...filters
},
headers,
signal: options?.signal,
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
})
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
if (response.status === 304) {
return {
notModified: true,
etag: etagHeader,
data: null
}
}
return {
notModified: false,
etag: etagHeader,
data: response.data
}
}
/** /**
* Get account by ID * Get account by ID
* @param id - Account ID * @param id - Account ID
@@ -455,6 +507,7 @@ export async function refreshOpenAIToken(
export const accountsAPI = { export const accountsAPI = {
list, list,
listWithEtag,
getById, getById,
create, create,
update, update,

View File

@@ -1280,6 +1280,8 @@ export default {
refreshInterval15s: '15 seconds', refreshInterval15s: '15 seconds',
refreshInterval30s: '30 seconds', refreshInterval30s: '30 seconds',
autoRefreshCountdown: 'Auto refresh: {seconds}s', autoRefreshCountdown: 'Auto refresh: {seconds}s',
listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.',
listPendingSyncAction: 'Sync now',
syncFromCrs: 'Sync from CRS', syncFromCrs: 'Sync from CRS',
dataExport: 'Export', dataExport: 'Export',
dataExportSelected: 'Export Selected', dataExportSelected: 'Export Selected',

View File

@@ -1368,6 +1368,8 @@ export default {
refreshInterval15s: '15 秒', refreshInterval15s: '15 秒',
refreshInterval30s: '30 秒', refreshInterval30s: '30 秒',
autoRefreshCountdown: '自动刷新:{seconds}s', autoRefreshCountdown: '自动刷新:{seconds}s',
listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。',
listPendingSyncAction: '立即同步',
syncFromCrs: '从 CRS 同步', syncFromCrs: '从 CRS 同步',
dataExport: '导出', dataExport: '导出',
dataExportSelected: '导出选中', dataExportSelected: '导出选中',

View File

@@ -12,7 +12,7 @@
/> />
<AccountTableActions <AccountTableActions
:loading="loading" :loading="loading"
@refresh="load" @refresh="handleManualRefresh"
@sync="showSync = true" @sync="showSync = true"
@create="showCreate = true" @create="showCreate = true"
> >
@@ -116,6 +116,18 @@
</template> </template>
</AccountTableActions> </AccountTableActions>
</div> </div>
<div
v-if="hasPendingListSync"
class="mt-2 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200"
>
<span>{{ t('admin.accounts.listPendingSyncHint') }}</span>
<button
class="btn btn-secondary px-2 py-1 text-xs"
@click="syncPendingListChanges"
>
{{ t('admin.accounts.listPendingSyncAction') }}
</button>
</div>
</template> </template>
<template #table> <template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" /> <AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
@@ -260,7 +272,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, toRaw } from 'vue'
import { useIntervalFn } from '@vueuse/core' import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
@@ -340,6 +352,11 @@ const autoRefreshIntervals = [5, 10, 15, 30] as const
const autoRefreshEnabled = ref(false) const autoRefreshEnabled = ref(false)
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30) const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
const autoRefreshCountdown = ref(0) const autoRefreshCountdown = ref(0)
const autoRefreshETag = ref<string | null>(null)
const autoRefreshFetching = ref(false)
const AUTO_REFRESH_SILENT_WINDOW_MS = 15000
const autoRefreshSilentUntil = ref(0)
const hasPendingListSync = ref(false)
const autoRefreshIntervalLabel = (sec: number) => { const autoRefreshIntervalLabel = (sec: number) => {
if (sec === 5) return t('admin.accounts.refreshInterval5s') if (sec === 5) return t('admin.accounts.refreshInterval5s')
@@ -437,11 +454,55 @@ const toggleColumn = (key: string) => {
const isColumnVisible = (key: string) => !hiddenColumns.has(key) const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({ const {
items: accounts,
loading,
params,
pagination,
load: baseLoad,
reload: baseReload,
debouncedReload: baseDebouncedReload,
handlePageChange: baseHandlePageChange,
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' } initialParams: { platform: '', type: '', status: '', search: '' }
}) })
const resetAutoRefreshCache = () => {
autoRefreshETag.value = null
}
const load = async () => {
hasPendingListSync.value = false
resetAutoRefreshCache()
await baseLoad()
}
const reload = async () => {
hasPendingListSync.value = false
resetAutoRefreshCache()
await baseReload()
}
const debouncedReload = () => {
hasPendingListSync.value = false
resetAutoRefreshCache()
baseDebouncedReload()
}
const handlePageChange = (page: number) => {
hasPendingListSync.value = false
resetAutoRefreshCache()
baseHandlePageChange(page)
}
const handlePageSizeChange = (size: number) => {
hasPendingListSync.value = false
resetAutoRefreshCache()
baseHandlePageSizeChange(size)
}
const isAnyModalOpen = computed(() => { const isAnyModalOpen = computed(() => {
return ( return (
showCreate.value || showCreate.value ||
@@ -459,21 +520,128 @@ const isAnyModalOpen = computed(() => {
) )
}) })
const enterAutoRefreshSilentWindow = () => {
autoRefreshSilentUntil.value = Date.now() + AUTO_REFRESH_SILENT_WINDOW_MS
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
}
const inAutoRefreshSilentWindow = () => {
return Date.now() < autoRefreshSilentUntil.value
}
const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
return (
current.updated_at !== next.updated_at ||
current.current_concurrency !== next.current_concurrency ||
current.current_window_cost !== next.current_window_cost ||
current.active_sessions !== next.active_sessions ||
current.schedulable !== next.schedulable ||
current.status !== next.status ||
current.rate_limit_reset_at !== next.rate_limit_reset_at ||
current.overload_until !== next.overload_until ||
current.temp_unschedulable_until !== next.temp_unschedulable_until
)
}
const syncAccountRefs = (nextAccount: Account) => {
if (edAcc.value?.id === nextAccount.id) edAcc.value = nextAccount
if (reAuthAcc.value?.id === nextAccount.id) reAuthAcc.value = nextAccount
if (tempUnschedAcc.value?.id === nextAccount.id) tempUnschedAcc.value = nextAccount
if (deletingAcc.value?.id === nextAccount.id) deletingAcc.value = nextAccount
if (menu.acc?.id === nextAccount.id) menu.acc = nextAccount
}
const mergeAccountsIncrementally = (nextRows: Account[]) => {
const currentRows = accounts.value
const currentByID = new Map(currentRows.map(row => [row.id, row]))
let changed = nextRows.length !== currentRows.length
const mergedRows = nextRows.map((nextRow) => {
const currentRow = currentByID.get(nextRow.id)
if (!currentRow) {
changed = true
return nextRow
}
if (shouldReplaceAutoRefreshRow(currentRow, nextRow)) {
changed = true
syncAccountRefs(nextRow)
return nextRow
}
return currentRow
})
if (!changed) {
for (let i = 0; i < mergedRows.length; i += 1) {
if (mergedRows[i].id !== currentRows[i]?.id) {
changed = true
break
}
}
}
if (changed) {
accounts.value = mergedRows
}
}
const refreshAccountsIncrementally = async () => {
if (autoRefreshFetching.value) return
autoRefreshFetching.value = true
try {
const result = await adminAPI.accounts.listWithEtag(
pagination.page,
pagination.page_size,
toRaw(params) as {
platform?: string
type?: string
status?: string
search?: string
},
{ etag: autoRefreshETag.value }
)
if (result.etag) {
autoRefreshETag.value = result.etag
}
if (result.notModified || !result.data) {
return
}
pagination.total = result.data.total || 0
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
} catch (error) {
console.error('Auto refresh failed:', error)
} finally {
autoRefreshFetching.value = false
}
}
const handleManualRefresh = async () => {
await load()
}
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
}
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn( const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
async () => { async () => {
if (!autoRefreshEnabled.value) return if (!autoRefreshEnabled.value) return
if (document.hidden) return if (document.hidden) return
if (loading.value) return if (loading.value || autoRefreshFetching.value) return
if (isAnyModalOpen.value) return if (isAnyModalOpen.value) return
if (menu.show) return if (menu.show) return
if (inAutoRefreshSilentWindow()) {
autoRefreshCountdown.value = Math.max(
0,
Math.ceil((autoRefreshSilentUntil.value - Date.now()) / 1000)
)
return
}
if (autoRefreshCountdown.value <= 0) { if (autoRefreshCountdown.value <= 0) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
try { await refreshAccountsIncrementally()
await load()
} catch (e) {
console.error('Auto refresh failed:', e)
}
return return
} }
@@ -723,22 +891,12 @@ const syncPaginationAfterLocalRemoval = () => {
pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0 pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0
const maxPage = Math.max(1, pagination.pages || 1) const maxPage = Math.max(1, pagination.pages || 1)
let shouldReload = false
if (pagination.page > maxPage) { if (pagination.page > maxPage) {
pagination.page = maxPage pagination.page = maxPage
shouldReload = nextTotal > 0
} else if (nextTotal > 0) {
const displayedEnd = (pagination.page - 1) * pagination.page_size + accounts.value.length
// 当前页条目变少时,若后续还有数据则补齐,避免空页/少一条直到手动刷新。
shouldReload = displayedEnd < nextTotal
}
if (shouldReload) {
load().catch((error) => {
console.error('Failed to refresh accounts after local removal:', error)
})
} }
// 行被本地移除后不立刻全量补页,改为提示用户手动同步。
hasPendingListSync.value = nextTotal > 0
} }
const patchAccountInList = (updatedAccount: Account) => { const patchAccountInList = (updatedAccount: Account) => {
@@ -758,14 +916,11 @@ const patchAccountInList = (updatedAccount: Account) => {
const nextAccounts = [...accounts.value] const nextAccounts = [...accounts.value]
nextAccounts[index] = mergedAccount nextAccounts[index] = mergedAccount
accounts.value = nextAccounts accounts.value = nextAccounts
if (edAcc.value?.id === mergedAccount.id) edAcc.value = mergedAccount syncAccountRefs(mergedAccount)
if (reAuthAcc.value?.id === mergedAccount.id) reAuthAcc.value = mergedAccount
if (tempUnschedAcc.value?.id === mergedAccount.id) tempUnschedAcc.value = mergedAccount
if (deletingAcc.value?.id === mergedAccount.id) deletingAcc.value = mergedAccount
if (menu.acc?.id === mergedAccount.id) menu.acc = mergedAccount
} }
const handleAccountUpdated = (updatedAccount: Account) => { const handleAccountUpdated = (updatedAccount: Account) => {
patchAccountInList(updatedAccount) patchAccountInList(updatedAccount)
enterAutoRefreshSilentWindow()
} }
const formatExportTimestamp = () => { const formatExportTimestamp = () => {
const now = new Date() const now = new Date()
@@ -820,6 +975,7 @@ const handleRefresh = async (a: Account) => {
try { try {
const updated = await adminAPI.accounts.refreshCredentials(a.id) const updated = await adminAPI.accounts.refreshCredentials(a.id)
patchAccountInList(updated) patchAccountInList(updated)
enterAutoRefreshSilentWindow()
} catch (error) { } catch (error) {
console.error('Failed to refresh credentials:', error) console.error('Failed to refresh credentials:', error)
} }
@@ -828,6 +984,7 @@ const handleResetStatus = async (a: Account) => {
try { try {
const updated = await adminAPI.accounts.clearError(a.id) const updated = await adminAPI.accounts.clearError(a.id)
patchAccountInList(updated) patchAccountInList(updated)
enterAutoRefreshSilentWindow()
appStore.showSuccess(t('common.success')) appStore.showSuccess(t('common.success'))
} catch (error) { } catch (error) {
console.error('Failed to reset status:', error) console.error('Failed to reset status:', error)
@@ -837,6 +994,7 @@ const handleClearRateLimit = async (a: Account) => {
try { try {
const updated = await adminAPI.accounts.clearRateLimit(a.id) const updated = await adminAPI.accounts.clearRateLimit(a.id)
patchAccountInList(updated) patchAccountInList(updated)
enterAutoRefreshSilentWindow()
appStore.showSuccess(t('common.success')) appStore.showSuccess(t('common.success'))
} catch (error) { } catch (error) {
console.error('Failed to clear rate limit:', error) console.error('Failed to clear rate limit:', error)
@@ -850,6 +1008,7 @@ const handleToggleSchedulable = async (a: Account) => {
try { try {
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable) const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable) updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
enterAutoRefreshSilentWindow()
} catch (error) { } catch (error) {
console.error('Failed to toggle schedulable:', error) console.error('Failed to toggle schedulable:', error)
appStore.showError(t('admin.accounts.failedToToggleSchedulable')) appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
@@ -865,6 +1024,7 @@ const handleTempUnschedReset = async () => {
showTempUnsched.value = false showTempUnsched.value = false
tempUnschedAcc.value = null tempUnschedAcc.value = null
patchAccountInList(updated) patchAccountInList(updated)
enterAutoRefreshSilentWindow()
} catch (error) { } catch (error) {
console.error('Failed to reset temp unscheduled:', error) console.error('Failed to reset temp unscheduled:', error)
} }