feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
commit
3a7e8f3b54
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import { useChannelKeyDisclosure } from '../use-channel-key-disclosure'
|
||||
|
||||
function Harness(props: { open: boolean; channelId: number }) {
|
||||
const disclosure = useChannelKeyDisclosure(props.open, props.channelId)
|
||||
return (
|
||||
<>
|
||||
<button type='button' onClick={disclosure.handleRevealKey}>
|
||||
Reveal
|
||||
</button>
|
||||
<output aria-label='Channel key'>
|
||||
{disclosure.channelKey ?? 'Hidden'}
|
||||
</output>
|
||||
<SecureVerificationDialog {...disclosure.verification.dialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function deferredResponse<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((finish) => {
|
||||
resolve = finish
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function channelVerification() {
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'channel.key.read',
|
||||
methods: [{ method: '2fa', available: true }],
|
||||
oauth_providers: [],
|
||||
password_encryption_enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
proof_token: 'channel-proof',
|
||||
method: '2fa',
|
||||
scope: 'channel.key.read',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it.each(['switch', 'close'] as const)(
|
||||
'discards a pending channel key response after %s',
|
||||
async (change) => {
|
||||
const proof = channelVerification()
|
||||
const keyReply = deferredResponse<{
|
||||
data: { success: boolean; data: { key: string } }
|
||||
}>()
|
||||
const post = vi.spyOn(api, 'post').mockImplementation((url) => {
|
||||
if (url === '/api/verify') return Promise.resolve(proof)
|
||||
if (url === '/api/channel/123/key') return keyReply.promise
|
||||
throw new Error(`Unexpected POST ${url}`)
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const view = render(<Harness open channelId={123} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal' }))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/channel/123/key',
|
||||
undefined,
|
||||
expect.anything()
|
||||
)
|
||||
)
|
||||
view.rerender(
|
||||
<Harness
|
||||
open={change !== 'close'}
|
||||
channelId={change === 'switch' ? 456 : 123}
|
||||
/>
|
||||
)
|
||||
await act(async () => {
|
||||
keyReply.resolve({
|
||||
data: { success: true, data: { key: 'CHANNEL_A_SECRET' } },
|
||||
})
|
||||
await keyReply.promise
|
||||
})
|
||||
expect(screen.getByLabelText('Channel key')).toHaveTextContent('Hidden')
|
||||
expect(screen.queryByText('CHANNEL_A_SECRET')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('cancels a pending verification when the selected channel changes', async () => {
|
||||
channelVerification()
|
||||
const reply = deferredResponse<{
|
||||
data: { success: boolean; data: Record<string, unknown> }
|
||||
}>()
|
||||
const post = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
|
||||
const user = userEvent.setup()
|
||||
const view = render(<Harness open channelId={123} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal' }))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
view.rerender(<Harness open channelId={456} />)
|
||||
await act(async () => {
|
||||
reply.resolve({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
proof_token: 'late-proof',
|
||||
scope: 'channel.key.read',
|
||||
method: '2fa',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
await reply.promise
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(post.mock.calls.map(([url]) => url)).toEqual(['/api/verify'])
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useSecureVerification } from '@/features/auth/secure-verification'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import { getChannelKey } from '../api'
|
||||
|
||||
export function useChannelKeyDisclosure(
|
||||
open: boolean,
|
||||
channelId: number | null
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const verification = useSecureVerification()
|
||||
const cancelVerification = verification.cancel
|
||||
const requestVerification = verification.requestVerification
|
||||
const [disclosedKey, setDisclosedKey] = useState<{
|
||||
channelId: number
|
||||
key: string
|
||||
} | null>(null)
|
||||
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
||||
const operation = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDisclosedKey(null)
|
||||
setIsChannelKeyLoading(false)
|
||||
return () => {
|
||||
operation.current?.abort()
|
||||
operation.current = null
|
||||
cancelVerification()
|
||||
}
|
||||
}, [open, channelId, cancelVerification])
|
||||
|
||||
const handleRevealKey = useCallback(async () => {
|
||||
if (!channelId || !open || operation.current) return
|
||||
const current = new AbortController()
|
||||
operation.current = current
|
||||
try {
|
||||
const proof = await requestVerification({
|
||||
scope: 'channel.key.read',
|
||||
context: { channel_id: channelId },
|
||||
title: t('Verify to view channel key'),
|
||||
description: t(
|
||||
'Use Passkey or 2FA to confirm your identity before revealing this channel key.'
|
||||
),
|
||||
})
|
||||
if (!proof || operation.current !== current) return
|
||||
setIsChannelKeyLoading(true)
|
||||
const res = await getChannelKey(
|
||||
channelId,
|
||||
proof.proof_token,
|
||||
current.signal
|
||||
)
|
||||
if (operation.current !== current) return
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch channel key'))
|
||||
}
|
||||
setDisclosedKey({ channelId, key: res.data?.key ?? '' })
|
||||
toast.success(t('Channel key unlocked'))
|
||||
} catch (error) {
|
||||
if (operation.current === current && !current.signal.aborted) {
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
}
|
||||
} finally {
|
||||
if (operation.current === current) {
|
||||
operation.current = null
|
||||
setIsChannelKeyLoading(false)
|
||||
}
|
||||
}
|
||||
}, [channelId, open, requestVerification, t])
|
||||
|
||||
const channelKey =
|
||||
open && disclosedKey?.channelId === channelId ? disclosedKey.key : null
|
||||
return { channelKey, isChannelKeyLoading, handleRevealKey, verification }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { createChannel, updateChannel } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import {
|
||||
transformFormDataToCreatePayload,
|
||||
transformFormDataToUpdatePayload,
|
||||
type ChannelFormValues,
|
||||
} from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
type UseChannelMutateFormParams = {
|
||||
currentRow?: Channel | null
|
||||
isEditing: boolean
|
||||
isMultiKeyChannel: boolean
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const SENSITIVE_UPDATE_FIELDS = [
|
||||
'type',
|
||||
'key',
|
||||
'base_url',
|
||||
'openai_organization',
|
||||
'param_override',
|
||||
'header_override',
|
||||
'setting',
|
||||
'settings',
|
||||
'other',
|
||||
] satisfies (keyof Channel)[]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string | undefined {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (!isRecord(error)) return undefined
|
||||
|
||||
const response = error.response
|
||||
if (isRecord(response)) {
|
||||
const data = response.data
|
||||
if (isRecord(data)) {
|
||||
const message = data.message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
}
|
||||
|
||||
const message = error.message
|
||||
if (typeof message === 'string') return message
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: ChannelFormValues): Promise<string> => {
|
||||
if (props.isEditing && props.currentRow) {
|
||||
const payload = transformFormDataToUpdatePayload(
|
||||
data,
|
||||
props.currentRow.id
|
||||
)
|
||||
if (!data.key?.trim()) {
|
||||
delete payload.key
|
||||
}
|
||||
if (!canEditSensitive) {
|
||||
for (const field of SENSITIVE_UPDATE_FIELDS) {
|
||||
delete payload[field]
|
||||
}
|
||||
}
|
||||
const payloadWithKeyMode =
|
||||
canEditSensitive &&
|
||||
props.isMultiKeyChannel &&
|
||||
data.key?.trim() &&
|
||||
data.key_mode
|
||||
? {
|
||||
...payload,
|
||||
key_mode: data.key_mode,
|
||||
}
|
||||
: payload
|
||||
|
||||
const response = await updateChannel(
|
||||
props.currentRow.id,
|
||||
payloadWithKeyMode
|
||||
)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
return SUCCESS_MESSAGES.UPDATED
|
||||
}
|
||||
|
||||
const payload = transformFormDataToCreatePayload(data)
|
||||
const response = await createChannel(payload)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
}
|
||||
return SUCCESS_MESSAGES.CREATED
|
||||
},
|
||||
onSuccess: (messageKey) => {
|
||||
toast.success(t(messageKey))
|
||||
props.onSuccess()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
|
||||
import { normalizeModelList } from '../lib/upstream-update-utils'
|
||||
|
||||
const upstreamUpdateRequestConfig = {
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
} satisfies ApiRequestConfig
|
||||
|
||||
function getManualIgnoredModelCount(settings: unknown): number {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
if (settings && typeof settings === 'object')
|
||||
parsed = settings as Record<string, unknown>
|
||||
else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
}
|
||||
if (!parsed) return 0
|
||||
return normalizeModelList(
|
||||
(parsed.upstream_model_update_ignored_models as unknown[]) || []
|
||||
).length
|
||||
}
|
||||
|
||||
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [channel, setChannel] = useState<{
|
||||
id: number
|
||||
[key: string]: unknown
|
||||
} | null>(null)
|
||||
const [addModels, setAddModels] = useState<string[]>([])
|
||||
const [removeModels, setRemoveModels] = useState<string[]>([])
|
||||
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
|
||||
const [applyLoading, setApplyLoading] = useState(false)
|
||||
const [detectAllLoading, setDetectAllLoading] = useState(false)
|
||||
const [applyAllLoading, setApplyAllLoading] = useState(false)
|
||||
|
||||
const applyRef = useRef(false)
|
||||
const detectRef = useRef(false)
|
||||
const detectAllRef = useRef(false)
|
||||
const applyAllRef = useRef(false)
|
||||
|
||||
const openModal = useCallback(
|
||||
(
|
||||
record: { id: number; [key: string]: unknown } | null,
|
||||
pendingAdd: string[] = [],
|
||||
pendingRemove: string[] = [],
|
||||
tab: 'add' | 'remove' = 'add'
|
||||
) => {
|
||||
const normAdd = normalizeModelList(pendingAdd)
|
||||
const normRemove = normalizeModelList(pendingRemove)
|
||||
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
|
||||
toast.info(t('No processable upstream model updates for this channel'))
|
||||
return
|
||||
}
|
||||
setChannel(record)
|
||||
setAddModels(normAdd)
|
||||
setRemoveModels(normRemove)
|
||||
setPreferredTab(tab)
|
||||
setShowModal(true)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false)
|
||||
setChannel(null)
|
||||
setAddModels([])
|
||||
setRemoveModels([])
|
||||
setPreferredTab('add')
|
||||
}, [])
|
||||
|
||||
const applyUpdates = useCallback(
|
||||
async ({
|
||||
addModels: selectedAdd = [],
|
||||
removeModels: selectedRemove = [],
|
||||
}: {
|
||||
addModels?: string[]
|
||||
removeModels?: string[]
|
||||
} = {}) => {
|
||||
if (applyRef.current) return
|
||||
if (!channel?.id) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
applyRef.current = true
|
||||
setApplyLoading(true)
|
||||
try {
|
||||
const normSelectedAdd = normalizeModelList(selectedAdd)
|
||||
const selectedAddSet = new Set(normSelectedAdd)
|
||||
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
|
||||
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/apply',
|
||||
{
|
||||
id: channel.id,
|
||||
add_models: normSelectedAdd,
|
||||
ignore_models: ignoreModels,
|
||||
remove_models: normalizeModelList(selectedRemove),
|
||||
},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Operation failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
|
||||
{
|
||||
added: data?.added_models?.length || 0,
|
||||
removed: data?.removed_models?.length || 0,
|
||||
ignored: normalizeModelList(ignoreModels).length,
|
||||
totalIgnored: getManualIgnoredModelCount(data?.settings),
|
||||
}
|
||||
)
|
||||
)
|
||||
closeModal()
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Operation failed')
|
||||
)
|
||||
} finally {
|
||||
applyRef.current = false
|
||||
setApplyLoading(false)
|
||||
}
|
||||
},
|
||||
[channel, addModels, closeModal, refresh, t]
|
||||
)
|
||||
|
||||
const applyAllUpdates = useCallback(async () => {
|
||||
if (applyAllRef.current) return
|
||||
applyAllRef.current = true
|
||||
setApplyAllLoading(true)
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/apply_all',
|
||||
{},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch processing failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed',
|
||||
{
|
||||
channels: data?.processed_channels || 0,
|
||||
added: data?.added_models || 0,
|
||||
removed: data?.removed_models || 0,
|
||||
fails: (data?.failed_channel_ids || []).length,
|
||||
}
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch processing failed')
|
||||
)
|
||||
} finally {
|
||||
applyAllRef.current = false
|
||||
setApplyAllLoading(false)
|
||||
}
|
||||
}, [refresh, t])
|
||||
|
||||
const detectChannelUpdates = useCallback(
|
||||
async (ch: { id: number; [key: string]: unknown } | null) => {
|
||||
if (detectRef.current || !ch?.id) return
|
||||
detectRef.current = true
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/detect',
|
||||
{ id: ch.id },
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t('Detection complete: {{add}} to add, {{remove}} to remove', {
|
||||
add: data?.add_models?.length || 0,
|
||||
remove: data?.remove_models?.length || 0,
|
||||
})
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Detection failed')
|
||||
)
|
||||
} finally {
|
||||
detectRef.current = false
|
||||
}
|
||||
},
|
||||
[refresh, t]
|
||||
)
|
||||
|
||||
const detectAllUpdates = useCallback(async () => {
|
||||
if (detectAllRef.current) return
|
||||
detectAllRef.current = true
|
||||
setDetectAllLoading(true)
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/detect_all',
|
||||
{},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.'
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch detection failed')
|
||||
)
|
||||
} finally {
|
||||
detectAllRef.current = false
|
||||
setDetectAllLoading(false)
|
||||
}
|
||||
}, [refresh, t])
|
||||
|
||||
// Memoized so consumers (and the channels context value built from this) get
|
||||
// a stable reference unless an actual field changes. Callbacks above are all
|
||||
// useCallback-stable, so this only changes when relevant state changes.
|
||||
return useMemo(
|
||||
() => ({
|
||||
showModal,
|
||||
channel,
|
||||
addModels,
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
closeModal,
|
||||
applyUpdates,
|
||||
applyAllUpdates,
|
||||
detectChannelUpdates,
|
||||
detectAllUpdates,
|
||||
}),
|
||||
[
|
||||
showModal,
|
||||
channel,
|
||||
addModels,
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
closeModal,
|
||||
applyUpdates,
|
||||
applyAllUpdates,
|
||||
detectChannelUpdates,
|
||||
detectAllUpdates,
|
||||
]
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user