Commit 4f24e2de by ccran

feat: add export deploy;

parent 417614a1
#!/bin/bash
# 本地构建镜像,上传远端,重建 Compose 服务并等待健康检查通过。
# 用法:bash bin/docker-remote-deploy.sh [user@host]
# 示例:SSH_PORT=22 IMAGE_TAG=new-api:export bash bin/docker-remote-deploy.sh
# SSH 使用本机 ~/.ssh/config、密钥或交互式密码认证,同次执行复用连接。
set -euo pipefail
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
sed -n '2,5p' "$0"
cat <<'HELP'
REMOTE_DIR 远端部署目录,默认 /opt/new-api(自动创建)
COMPOSE_FILE 本地 Compose 文件,默认项目根目录的 docker-compose.yml
SERVICE_NAME 要重建的服务,默认 new-api
IMAGE_TAG 必须与本地服务配置一致,默认 new-api:export
HEALTH_TIMEOUT 健康检查等待秒数,默认 180
SSH_PORT SSH 端口,默认 22
远端需已安装 Docker 和 Compose v2,服务需配置 healthcheck,依赖服务需已启动。
自动上传本地 Compose 文件;远端原配置备份为 docker-compose.yml.bak。
只重建指定服务;检查失败时返回非零状态,不自动回滚。
HELP
exit 0
fi
if (( $# > 1 )); then
echo "用法:bash $0 [user@host]" >&2
exit 1
fi
# 所有阶段日志写入 stderr,避免污染捕获的架构、镜像等命令结果。
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
step() {
current_step="$1"
log "开始 [$current_step/10] $2"
}
current_step=1
failed_command=''
failed_line=''
trap 'failed_command=$BASH_COMMAND; failed_line=$LINENO' ERR
trap 'result=$?; if (( result != 0 )); then log "失败 [$current_step/10] 退出码=${result},行号=${failed_line:-未知},命令=${failed_command:-见上方错误}"; fi' EXIT
step 1 "检查本地参数和构建环境"
remote_host=${1:-root@8.136.9.68}
ssh_port=${SSH_PORT:-22}
image_tag=${IMAGE_TAG:-new-api:export}
remote_dir=${REMOTE_DIR:-/opt/new-api}
compose_file=${COMPOSE_FILE:-docker-compose.yml}
service_name=${SERVICE_NAME:-new-api}
health_timeout=${HEALTH_TIMEOUT:-180}
project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
if [[ "$compose_file" != /* ]]; then
compose_file="$project_dir/$compose_file"
fi
if [[ ! -f "$compose_file" ]]; then
echo "找不到本地 Compose 文件:$compose_file" >&2
exit 1
fi
if [[ "$remote_dir" != /* || ! "$health_timeout" =~ ^[1-9][0-9]{0,5}$ || "$service_name" == -* ]]; then
echo "REMOTE_DIR 必须是绝对路径,HEALTH_TIMEOUT 必须为 1–999999 秒,SERVICE_NAME 不能以 - 开头。" >&2
exit 1
fi
if [[ "$remote_host" == -* || "$remote_host" == *[[:space:]]* || -z "$remote_host" ]]; then
echo "无效的 SSH 目标:$remote_host" >&2
exit 1
fi
if [[ ! "$ssh_port" =~ ^[0-9]+$ ]]; then
echo "SSH_PORT 必须是端口号。" >&2
exit 1
fi
for command_name in docker ssh scp; do
command -v "$command_name" >/dev/null || {
echo "缺少命令:$command_name" >&2
exit 1
}
done
docker info >/dev/null
docker buildx version
log "完成 [1/10] 本地环境检查通过;目标=${remote_host},目录=${remote_dir},镜像=$image_tag"
# 使用短路径,避免 macOS 的临时目录导致 SSH 控制套接字路径过长。
# 临时文件放在构建上下文之外,避免将导出的镜像再次打包进镜像。
export_dir=$(mktemp -d /tmp/new-api-export.XXXXXX)
control_path="$export_dir/ssh"
ssh_options=(-o "ControlPath=$control_path" -o ControlMaster=no -o BatchMode=yes)
cleanup() {
result=$?
if (( result != 0 )); then
log "失败 [$current_step/10] 退出码=${result},行号=${failed_line:-未知},命令=${failed_command:-见上方错误}"
fi
log "清理本地临时文件并关闭 SSH 复用连接"
ssh "${ssh_options[@]}" -p "$ssh_port" -O exit "$remote_host" >/dev/null 2>&1 || true
rm -rf -- "$export_dir" || true
return "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
archive_path="$export_dir/new-api-export.tar"
step 2 "建立 SSH 连接(仅此处需要密码)"
# 主连接只认证一次,并在耗时的镜像构建期间保持连接。
ssh -p "$ssh_port" -o "ControlPath=$control_path" -o ControlMaster=yes \
-o ControlPersist=no -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-fN "$remote_host"
log "完成 [2/10] SSH 连接已建立,后续操作复用此连接"
# 参数采用 POSIX 单引号转义,避免远端登录 shell 再次解释路径或变量。
remote_args=()
single_quote_escape="'\\''"
for argument in "$remote_dir" "docker-compose.yml" "$service_name" "$image_tag" "$health_timeout"; do
remote_args+=("'${argument//\'/$single_quote_escape}'")
done
step 3 "检查远端 Docker、Compose 和 CPU 架构"
remote_arch=$(ssh "${ssh_options[@]}" -p "$ssh_port" "$remote_host" "bash -s -- ${remote_args[*]}" <<'REMOTE'
set -euo pipefail
trap 'result=$?; printf "[远端预检失败] 行号=%s,退出码=%s,命令=%s\n" "$LINENO" "$result" "$BASH_COMMAND" >&2; exit "$result"' ERR
printf '[远端] 检查 Docker 服务及权限\n' >&2
docker info >/dev/null
docker compose version >&2
uname -m
REMOTE
)
case "$remote_arch" in
x86_64) platform=linux/amd64 ;;
aarch64|arm64) platform=linux/arm64 ;;
*) echo "不支持的远端架构:$remote_arch" >&2; exit 1 ;;
esac
log "完成 [3/10] 远端架构=${remote_arch},构建平台=$platform"
step 4 "本地构建 ${image_tag}${platform},跨架构构建可能较慢)"
docker buildx build --progress plain --platform "$platform" --load \
--tag "$image_tag" --file "$project_dir/Dockerfile" "$project_dir"
log "完成 [4/10] 镜像构建成功"
step 5 "导出镜像并准备 Compose 配置"
cp -- "$compose_file" "$export_dir/docker-compose.yml"
docker save -o "$archive_path" "$image_tag"
log "完成 [5/10] 镜像归档大小:$(du -h "$archive_path" | cut -f1);配置=$compose_file"
remote_archive="new-api-deploy-${export_dir##*/}.tar"
step 6 "上传镜像和 Compose 配置"
log "上传镜像至 $remote_host:~/$remote_archive"
scp "${ssh_options[@]}" -P "$ssh_port" "$archive_path" "$remote_host:$remote_archive"
log "镜像上传完成,开始上传 Compose 配置"
scp "${ssh_options[@]}" -P "$ssh_port" "$export_dir/docker-compose.yml" "$remote_host:$remote_archive.yml"
log "完成 [6/10] 镜像和配置上传成功"
current_step="7–10"
log "进入远端部署阶段,详细步骤见下方日志"
ssh "${ssh_options[@]}" -p "$ssh_port" "$remote_host" "bash -s -- ${remote_args[*]} '$remote_archive'" <<'REMOTE'
set -euo pipefail
log() {
printf '[%s] [远端] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
step() {
current_step="$1"
log "开始 [$current_step/10] $2"
}
current_step=7
failed_command=''
failed_line=''
trap 'failed_command=$BASH_COMMAND; failed_line=$LINENO' ERR
cleanup() {
result=$?
if (( result != 0 )); then
log "失败 [$current_step/10] 退出码=${result},行号=${failed_line:-未知},命令=${failed_command:-见上方错误}"
if [[ "$current_step" == 9 || "$current_step" == 10 ]]; then
log "以下为服务状态与最近 50 行日志"
"${compose[@]}" ps -a "$service" >&2 || true
"${compose[@]}" logs --no-color --tail 50 "$service" >&2 || true
fi
fi
rm -f -- "$HOME/$6" "$HOME/$6.yml" || true
return "$result"
}
# EXIT trap 显式保留脚本参数,供清理函数定位上传文件。
trap 'cleanup "$@"' EXIT
step 7 "检查上传的 Compose 配置并准备部署目录"
mkdir -p -- "$1"
cd -- "$1"
compose=(docker compose -f "$2")
service=$3
image=$4
timeout=$5
archive="$HOME/$6"
config_upload="$archive.yml"
docker compose --project-directory "$1" -f "$config_upload" config --quiet
configured_services=$(docker compose --project-directory "$1" -f "$config_upload" config --services)
service_found=false
while IFS= read -r configured_service; do
if [[ "$configured_service" == "$service" ]]; then
service_found=true
break
fi
done <<< "$configured_services"
if [[ "$service_found" != true ]]; then
log "上传的 Compose 配置中不存在服务 $service"
exit 1
fi
log "镜像 ID 和健康检查将在服务重建后通过 docker inspect 验证"
log "完成 [7/10] Compose 配置检查通过"
step 8 "导入镜像并应用 Compose 配置"
docker load -i "$archive"
expected_image=$(docker image inspect --format '{{.Id}}' "$image")
if [[ -f "$2" ]]; then
cp -p -- "$2" "$2.bak"
log "原配置已备份至 $1/$2.bak"
fi
cp -- "$config_upload" "$2"
log "完成 [8/10] 镜像=${expected_image},配置=$1/$2"
step 9 "重建服务 $service"
"${compose[@]}" up -d --no-deps --force-recreate --no-build --pull never "$service"
container_ids=$("${compose[@]}" ps -a -q "$service")
if [[ -z "$container_ids" ]]; then
echo "服务 $service 未创建容器。" >&2
exit 1
fi
log "完成 [9/10] 服务已重建,容器=$container_ids"
step 10 "等待健康检查,最多 ${timeout}"
deadline=$((SECONDS + timeout))
while :; do
all_healthy=true
for container in $container_ids; do
state=$(docker inspect --format '{{.Image}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container")
read -r actual_image status health <<< "$state"
remaining=$((deadline - SECONDS))
if (( remaining < 0 )); then remaining=0; fi
log "容器=${container},运行状态=${status},健康状态=${health},剩余等待=${remaining}"
if [[ "$actual_image" != "$expected_image" || "$health" == missing || "$status" == exited || "$status" == dead ]]; then
echo "容器 $container 检查失败:${state}(需要配置 healthcheck 并使用本次镜像)。" >&2
"${compose[@]}" ps -a "$service" >&2
exit 1
fi
if [[ "$status" != running || "$health" != healthy ]]; then
all_healthy=false
fi
done
if [[ "$all_healthy" == true ]]; then
log "完成 [10/10] 服务 $service 的全部容器健康检查通过"
break
fi
if (( SECONDS >= deadline )); then
echo "健康检查超过 ${timeout} 秒,部署失败;请检查远端容器日志。" >&2
"${compose[@]}" ps -a "$service" >&2
exit 1
fi
sleep 2
done
REMOTE
log "部署完成(总耗时 ${SECONDS} 秒):${remote_host}${service_name} 已更新为 ${image_tag}${platform})并通过健康检查。"
#!/bin/bash
# 在 Mac(包括 Apple Silicon)上构建目标架构镜像,上传到 Ubuntu 并导入。
# 用法:bash bin/docker-upload-ubuntu.sh [user@host]
# 示例:SSH_PORT=22 IMAGE_TAG=new-api:export bash bin/docker-upload-ubuntu.sh
# SSH 使用本机 ~/.ssh/config、密钥或交互式密码认证,同次执行复用连接。
set -euo pipefail
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
sed -n '2,5p' "$0"
exit 0
fi
if (( $# > 1 )); then
echo "用法:bash $0 [user@host]" >&2
exit 1
fi
remote_host=${1:-root@8.136.9.68}
ssh_port=${SSH_PORT:-22}
image_tag=${IMAGE_TAG:-new-api:export}
project_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
if [[ "$remote_host" == -* || "$remote_host" == *[[:space:]]* || -z "$remote_host" ]]; then
echo "无效的 SSH 目标:$remote_host" >&2
exit 1
fi
if [[ ! "$ssh_port" =~ ^[0-9]+$ ]]; then
echo "SSH_PORT 必须是端口号。" >&2
exit 1
fi
for command_name in docker ssh scp; do
command -v "$command_name" >/dev/null || {
echo "缺少命令:$command_name" >&2
exit 1
}
done
docker info >/dev/null
docker buildx version >/dev/null
# 使用短路径,避免 macOS 的临时目录导致 SSH 控制套接字路径过长。
# 临时文件放在构建上下文之外,避免将导出的镜像再次打包进镜像。
export_dir=$(mktemp -d /tmp/new-api-export.XXXXXX)
control_path="$export_dir/ssh"
ssh_options=(-o "ControlPath=$control_path" -o ControlMaster=no -o BatchMode=yes)
cleanup() {
ssh "${ssh_options[@]}" -p "$ssh_port" -O exit "$remote_host" >/dev/null 2>&1 || true
rm -rf -- "$export_dir"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
archive_path="$export_dir/new-api-export.tar"
# 主连接只认证一次,并在耗时的镜像构建期间保持连接。
ssh -p "$ssh_port" -o "ControlPath=$control_path" -o ControlMaster=yes \
-o ControlPersist=no -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-fN "$remote_host"
echo "检查 Ubuntu 架构及 Docker 权限:$remote_host"
remote_arch=$(ssh "${ssh_options[@]}" -p "$ssh_port" "$remote_host" 'set -e; docker info >/dev/null; uname -m')
case "$remote_arch" in
x86_64) platform=linux/amd64 ;;
aarch64|arm64) platform=linux/arm64 ;;
*) echo "不支持的远端架构:$remote_arch" >&2; exit 1 ;;
esac
echo "构建 ${image_tag},目标平台:${platform}(跨架构构建可能较慢)"
docker buildx build --platform "$platform" --load \
--tag "$image_tag" --file "$project_dir/Dockerfile" "$project_dir"
echo "导出镜像"
docker save -o "$archive_path" "$image_tag"
echo "上传至 $remote_host:~/new-api-export.tar"
scp "${ssh_options[@]}" -P "$ssh_port" "$archive_path" "$remote_host:new-api-export.tar"
echo "在 Ubuntu 导入镜像"
ssh "${ssh_options[@]}" -p "$ssh_port" "$remote_host" 'docker load -i "$HOME/new-api-export.tar"'
echo "完成:远端已导入 ${image_tag}${platform})。"
echo "远端归档保留在 ~/new-api-export.tar;本地临时归档会自动删除。"
echo "现有容器不会自动重启,请在确认部署配置后重新创建容器。"
......@@ -119,8 +119,11 @@ export async function searchChannels(
/**
* Get single channel by ID
*/
export async function getChannel(id: number): Promise<GetChannelResponse> {
const res = await api.get(`/api/channel/${id}`)
export async function getChannel(
id: number,
config?: ApiRequestConfig
): Promise<GetChannelResponse> {
const res = await api.get(`/api/channel/${id}`, config)
return res.data
}
......
/*
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 fs from 'node:fs'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import i18next from 'i18next'
import { toast } from 'sonner'
import { expect, it, vi } from 'vitest'
import * as XLSX from 'xlsx'
import { api } from '@/lib/api'
import { usageLogSchema } from '../../data/schema'
import { buildLogsWorkbook, collectExportLogs } from '../../lib/export'
import type { FetchLogsConfig, MidjourneyLog, TaskLog } from '../../types'
import { LogsExportButton } from '../logs-export-button'
const config: FetchLogsConfig = {
logCategory: 'common',
isAdmin: false,
page: 4,
pageSize: 20,
searchParams: {
startTime: 1700000000000,
endTime: 1700000100000,
type: ['2'],
model: 'gpt-test',
token: 'work',
group: 'default',
requestId: 'req-1',
},
columnFilters: [],
}
const log = usageLogSchema.parse({
id: 1,
user_id: 9,
created_at: 1700000000,
type: 2,
content: '=SUM(1,2)',
prompt_tokens: 0,
completion_tokens: 12,
quota: 500,
username: 'admin-only',
channel: 3,
})
it('exports every filtered page through the self endpoint, starting at page one', async () => {
const get = vi
.spyOn(api, 'get')
.mockResolvedValueOnce({
data: {
success: true,
data: { items: Array.from({ length: 100 }, () => log), total: 101 },
},
})
.mockResolvedValueOnce({
data: { success: true, data: { items: [log], total: 101 } },
})
const progress = vi.fn()
const logs = await collectExportLogs(
config,
new AbortController().signal,
progress,
i18next.t
)
expect(logs).toHaveLength(101)
const urls = get.mock.calls.map(
([url]) => new URL(url, 'https://example.test')
)
expect(urls.map((url) => url.pathname)).toEqual([
'/api/log/self',
'/api/log/self',
])
expect(urls.map((url) => url.searchParams.get('p'))).toEqual(['1', '2'])
expect(Object.fromEntries(urls[1].searchParams)).toMatchObject({
page_size: '100',
model_name: 'gpt-test',
token_name: 'work',
group: 'default',
request_id: 'req-1',
type: '2',
start_timestamp: '1700000000',
end_timestamp: '1700000100',
})
expect(progress.mock.calls).toEqual([[100], [101]])
})
it.each([
{ success: false, message: 'Export denied' },
{ success: true, data: { items: [log], total: 10001 } },
])(
'rejects failed or oversized exports without returning partial data: %j',
async (response) => {
vi.spyOn(api, 'get').mockResolvedValue({ data: response })
await expect(
collectExportLogs(
config,
new AbortController().signal,
vi.fn(),
i18next.t
)
).rejects.toThrow()
}
)
it('rejects an export when a later page fails', async () => {
vi.spyOn(api, 'get')
.mockResolvedValueOnce({
data: { success: true, data: { items: [log], total: 2 } },
})
.mockRejectedValueOnce(new Error('Network unavailable'))
await expect(
collectExportLogs(config, new AbortController().signal, vi.fn(), i18next.t)
).rejects.toThrow('Network unavailable')
})
it('writes valid Excel cells preserving zero, booleans and formula-like text, omitting admin fields in self view', async () => {
const workbook = await buildLogsWorkbook([log], config, i18next.t)
const parsed = XLSX.read(
XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
{ type: 'array' }
)
const rows = XLSX.utils.sheet_to_json(parsed.Sheets.Logs)
expect(rows).toEqual([
expect.objectContaining({
'Input Tokens': 0,
'Output Tokens': 12,
Stream: false,
Content: '=SUM(1,2)',
Quota: 500,
}),
])
expect(rows[0]).not.toHaveProperty('Username')
expect(rows[0]).not.toHaveProperty('Channel')
expect(parsed.Sheets.Logs.O2.f).toBeUndefined()
expect(parsed.Sheets.Logs.O2.t).toBe('s')
const adminWorkbook = await buildLogsWorkbook(
[log],
{ ...config, isAdmin: true },
i18next.t
)
expect(XLSX.utils.sheet_to_json(adminWorkbook.Sheets.Logs)[0]).toMatchObject({
Username: 'admin-only',
Channel: 3,
})
})
it('exports drawing millisecond timestamps and task second timestamps consistently', async () => {
const drawing: MidjourneyLog = {
id: 1,
user_id: 9,
channel_id: 3,
code: 1,
mj_id: 'mj-1',
action: 'IMAGINE',
submit_time: 1700000000000,
progress: '100%',
prompt: 'A tree',
status: 'SUCCESS',
}
const task: TaskLog = {
id: 2,
user_id: 9,
channel_id: 3,
task_id: 'task-1',
action: 'GENERATE',
submit_time: 1700000000,
platform: 'video',
group: 'default',
quota: 500,
status: 'SUCCESS',
}
const drawingBook = await buildLogsWorkbook(
[drawing],
{ logCategory: 'drawing', isAdmin: false },
i18next.t
)
const taskBook = await buildLogsWorkbook(
[task],
{ logCategory: 'task', isAdmin: true },
i18next.t
)
expect(drawingBook.Sheets.Logs.B2.v).toBe(taskBook.Sheets.Logs.B2.v)
expect(XLSX.utils.sheet_to_json(drawingBook.Sheets.Logs)[0]).toMatchObject({
'Task ID': 'mj-1',
Prompt: 'A tree',
})
expect(XLSX.utils.sheet_to_json(taskBook.Sheets.Logs)[0]).toMatchObject({
'Task ID': 'task-1',
Quota: 500,
'User ID': 9,
'Channel ID': 3,
})
})
it('shows an empty result message and re-enables export when no logs match', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: { items: null, total: 0 } },
})
const info = vi.spyOn(toast, 'info').mockImplementation(() => 1)
render(<LogsExportButton config={config} />)
await userEvent.click(screen.getByRole('button', { name: 'Export Excel' }))
await waitFor(() => expect(info).toHaveBeenCalledWith('No Logs Found'))
expect(screen.getByRole('button', { name: 'Export Excel' })).toBeEnabled()
})
it('disables repeated exports while fetching and allows cancellation without downloading', async () => {
let resolveRequest!: (value: unknown) => void
const get = vi.spyOn(api, 'get').mockImplementation(
() =>
new Promise((resolve) => {
resolveRequest = resolve
})
)
render(<LogsExportButton config={config} />)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Export Excel' }))
expect(
screen.getByRole('button', { name: 'Exporting 0 rows' })
).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
resolveRequest({
data: { success: true, data: { items: [log], total: 101 } },
})
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Export Excel' })).toBeEnabled()
)
expect(get).toHaveBeenCalledTimes(1)
})
it('shows export failure and allows retry after a request fails', async () => {
vi.spyOn(api, 'get').mockRejectedValue(new Error('Network unavailable'))
const error = vi.spyOn(toast, 'error').mockImplementation(() => 1)
render(<LogsExportButton config={config} />)
await userEvent.click(screen.getByRole('button', { name: 'Export Excel' }))
await waitFor(() => expect(error).toHaveBeenCalledWith('Network unavailable'))
expect(screen.getByRole('button', { name: 'Export Excel' })).toBeEnabled()
})
it('downloads an Excel workbook after a successful export', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: { items: [log], total: 1 } },
})
const download = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {})
render(<LogsExportButton config={config} />)
await userEvent.click(screen.getByRole('button', { name: 'Export Excel' }))
await waitFor(() =>
expect(download).toHaveBeenCalledWith(
expect.stringMatching(/^usage-logs-common-\d+\.xlsx$/),
expect.anything()
)
)
expect(screen.getByRole('button', { name: 'Export Excel' })).toBeEnabled()
})
it('cancels the previous export when the log scope changes', async () => {
let resolveRequest!: (value: unknown) => void
vi.spyOn(api, 'get').mockImplementation(
() =>
new Promise((resolve) => {
resolveRequest = resolve
})
)
const download = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {})
const { rerender } = render(
<LogsExportButton config={{ ...config, isAdmin: true }} />
)
await userEvent.click(screen.getByRole('button', { name: 'Export Excel' }))
rerender(<LogsExportButton config={config} />)
await act(async () => {
resolveRequest({
data: { success: true, data: { items: [log], total: 1 } },
})
})
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Export Excel' })).toBeEnabled()
)
expect(download).not.toHaveBeenCalled()
})
const namedTask: TaskLog = {
id: 1,
user_id: 9,
username: 'alice',
channel_id: 3,
task_id: 'task-export',
action: 'GENERATE',
submit_time: 1700000000,
platform: 'video',
group: 'default',
quota: 500,
status: 'SUCCESS',
}
it('exports user and channel names alongside IDs and looks up each channel only once', async () => {
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: url.startsWith('/api/task?')
? {
success: true,
data: { items: [namedTask, { ...namedTask, id: 2 }], total: 2 },
}
: { success: true, data: { id: 3, name: 'Video production' } },
}))
const taskConfig = { ...config, logCategory: 'task' as const, isAdmin: true }
const logs = await collectExportLogs(
taskConfig,
new AbortController().signal,
vi.fn(),
i18next.t
)
const workbook = await buildLogsWorkbook(logs, taskConfig, i18next.t)
expect(XLSX.utils.sheet_to_json(workbook.Sheets.Logs)).toEqual([
expect.objectContaining({
'User ID': 9,
Username: 'alice',
'Channel ID': 3,
'Channel Name': 'Video production',
}),
expect.objectContaining({
'User ID': 9,
Username: 'alice',
'Channel ID': 3,
'Channel Name': 'Video production',
}),
])
expect(
get.mock.calls.filter(([url]) => url === '/api/channel/3')
).toHaveLength(1)
})
it.each(['missing', 'unavailable'])(
'preserves IDs with unknown names when user or channel is %s',
async (failure) => {
vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url.startsWith('/api/task?')) {
return {
data: {
success: true,
data: { items: [{ ...namedTask, username: undefined }], total: 1 },
},
}
}
if (failure === 'unavailable') throw new Error('Network unavailable')
return { data: { success: false, message: 'Channel deleted' } }
})
const taskConfig = {
...config,
logCategory: 'task' as const,
isAdmin: true,
}
const logs = await collectExportLogs(
taskConfig,
new AbortController().signal,
vi.fn(),
i18next.t
)
const workbook = await buildLogsWorkbook(logs, taskConfig, i18next.t)
expect(XLSX.utils.sheet_to_json(workbook.Sheets.Logs)[0]).toMatchObject({
'User ID': 9,
Username: 'Unknown',
'Channel ID': 3,
'Channel Name': 'Unknown',
})
}
)
it('does not request or export channel and user identity columns for self task logs', async () => {
const get = vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: { items: [namedTask], total: 1 } },
})
const taskConfig = { ...config, logCategory: 'task' as const }
const logs = await collectExportLogs(
taskConfig,
new AbortController().signal,
vi.fn(),
i18next.t
)
const workbook = await buildLogsWorkbook(logs, taskConfig, i18next.t)
const row = XLSX.utils.sheet_to_json(workbook.Sheets.Logs)[0]
expect(row).not.toHaveProperty('Username')
expect(row).not.toHaveProperty('Channel Name')
expect(get).toHaveBeenCalledTimes(1)
expect(get.mock.calls[0][0]).toContain('/api/task/self?')
})
it('cancels export while channel names are loading without returning a workbook', async () => {
const controller = new AbortController()
vi.spyOn(api, 'get').mockImplementation(async (url, options) => {
if (url.startsWith('/api/task?')) {
return { data: { success: true, data: { items: [namedTask], total: 1 } } }
}
expect(options?.signal).toBe(controller.signal)
expect(options).toMatchObject({
skipBusinessError: true,
skipErrorHandler: true,
})
controller.abort()
throw new DOMException('Cancelled', 'AbortError')
})
await expect(
collectExportLogs(
{ ...config, logCategory: 'task', isAdmin: true },
controller.signal,
vi.fn(),
i18next.t
)
).rejects.toThrow()
})
......@@ -111,6 +111,7 @@ function buildSearchSourceKey(values: {
}
interface CommonLogsFilterBarProps<TData> {
exportAction?: React.ReactNode
table: Table<TData>
}
......@@ -486,7 +487,12 @@ export function CommonLogsFilterBar<TData>(
table={props.table}
compactMobile
stats={statsBar}
actionStart={sensitiveToggle}
actionStart={
<>
{sensitiveToggle}
{props.exportAction}
</>
}
primaryFilters={
<>
{dateRangeFilter}
......
/*
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 { Download, Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
buildLogsWorkbook,
collectExportLogs,
MAX_EXPORT_LOGS,
} from '../lib/export'
import type { FetchLogsConfig } from '../types'
export function LogsExportButton(props: {
config: FetchLogsConfig
disabled?: boolean
}) {
const { t } = useTranslation()
const [progress, setProgress] = useState<number | null>(null)
const activeExport = useRef<AbortController | null>(null)
const scopeKey = JSON.stringify(props.config)
useEffect(() => {
setProgress(null)
return () => {
activeExport.current?.abort()
activeExport.current = null
}
}, [scopeKey])
const handleExport = async () => {
if (activeExport.current) return
const controller = new AbortController()
activeExport.current = controller
setProgress(0)
try {
const logs = await collectExportLogs(
props.config,
controller.signal,
setProgress,
t
)
if (logs.length === 0) {
toast.info(t('No Logs Found'))
return
}
const workbook = await buildLogsWorkbook(logs, props.config, t)
const XLSX = await import('xlsx')
controller.signal.throwIfAborted()
XLSX.writeFile(
workbook,
`usage-logs-${props.config.logCategory}-${Date.now()}.xlsx`,
{ compression: true }
)
} catch (error) {
if (!controller.signal.aborted) {
toast.error(
error instanceof Error ? error.message : t('Failed to export logs')
)
}
} finally {
if (activeExport.current === controller) {
activeExport.current = null
setProgress(null)
}
}
}
return (
<>
<Button
variant='outline'
disabled={props.disabled || progress !== null}
aria-busy={progress !== null}
title={t('Export applied filters to Excel (up to {{count}} rows).', {
count: MAX_EXPORT_LOGS,
})}
onClick={handleExport}
>
{progress !== null ? (
<Loader2 className='animate-spin' aria-hidden='true' />
) : (
<Download aria-hidden='true' />
)}
{progress !== null
? t('Exporting {{count}} rows', { count: progress })
: t('Export Excel')}
</Button>
{progress !== null && (
<Button
variant='ghost'
onClick={() => {
activeExport.current?.abort()
activeExport.current = null
setProgress(null)
}}
>
{t('Cancel')}
</Button>
)}
</>
)
}
......@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table'
import type { Table } from '@tanstack/react-table'
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
......@@ -39,6 +39,7 @@ type TaskLikeLogCategory = Extract<LogCategory, 'drawing' | 'task'>
type TaskLogsFilters = DrawingLogFilters | TaskLogFilters
interface TaskLogsFilterBarProps<TData> {
exportAction?: React.ReactNode
table: Table<TData>
logCategory: TaskLikeLogCategory
}
......@@ -201,6 +202,7 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
return (
<LogsFilterToolbar
actionStart={props.exportAction}
table={props.table}
primaryFilters={
<>
......
......@@ -41,6 +41,7 @@ import { parseLogOther } from '../lib/format'
import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { LogsExportButton } from './logs-export-button'
import { TaskLogsFilterBar } from './task-logs-filter-bar'
import { UsageLogsMobileList } from './usage-logs-mobile-card'
import { useLogsViewScope, type LogsViewAccess } from './usage-logs-provider'
......@@ -187,6 +188,19 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
})
const isCommon = logCategory === 'common'
const exportAction = (
<LogsExportButton
config={{
logCategory,
isAdmin,
page: 1,
pageSize: 100,
searchParams,
columnFilters,
}}
disabled={isFetching || !logs.length}
/>
)
return (
<DataTablePage
......@@ -213,9 +227,13 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
}
toolbar={
isCommon ? (
<CommonLogsFilterBar table={table} />
<CommonLogsFilterBar table={table} exportAction={exportAction} />
) : (
<TaskLogsFilterBar table={table} logCategory={logCategory} />
<TaskLogsFilterBar
table={table}
logCategory={logCategory}
exportAction={exportAction}
/>
)
}
renderRow={(row) => {
......
/*
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 type { TFunction } from 'i18next'
import type { WorkBook } from 'xlsx'
import { getChannel } from '@/features/channels/api'
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
import type { UsageLog } from '../data/schema'
import type { FetchLogsConfig, MidjourneyLog, TaskLog } from '../types'
import {
fetchLogsByCategory,
getDefaultTimeRange,
getLogTypeConfig,
} from './utils'
export const MAX_EXPORT_LOGS = 10_000
type ExportTaskLog = TaskLog & { channel_name?: string }
type ExportLog = UsageLog | MidjourneyLog | ExportTaskLog
export async function collectExportLogs(
config: FetchLogsConfig,
signal: AbortSignal,
onProgress: (count: number) => void,
t: TFunction
): Promise<ExportLog[]> {
const defaults = getDefaultTimeRange()
const searchParams = { ...config.searchParams }
if (!(searchParams.startTime ?? searchParams.endTime)) {
searchParams.startTime = defaults.start.getTime()
searchParams.endTime = defaults.end.getTime()
}
// Keep the time window fixed while fetching subsequent pages.
searchParams.endTime = Math.min(
Number(searchParams.endTime) || Date.now(),
Date.now()
)
const logs: ExportLog[] = []
let total = 0
for (let page = 1; page === 1 || logs.length < total; page++) {
signal.throwIfAborted()
const result = await fetchLogsByCategory({
...config,
searchParams,
page,
pageSize: 100,
})
signal.throwIfAborted()
if (!result.success || !result.data) {
throw new Error(result.message || t('Failed to load logs'))
}
const items = result.data.items ?? []
if (page === 1) total = result.data.total
if (
total > MAX_EXPORT_LOGS ||
logs.length + items.length > MAX_EXPORT_LOGS
) {
throw new Error(
t(
'Too many logs to export. Narrow the filters to {{count}} rows or fewer.',
{ count: MAX_EXPORT_LOGS }
)
)
}
if (
result.data.total !== total ||
(items.length === 0 && logs.length < total)
) {
throw new Error(t('Logs changed during export. Please try again.'))
}
logs.push(...items)
onProgress(logs.length)
}
if (config.logCategory === 'task' && config.isAdmin && logs.length > 0) {
const tasks = logs as ExportTaskLog[]
const channelIds = [...new Set(tasks.map((log) => log.channel_id))].filter(
(id) => id > 0
)
const channelNames = new Map<number, string>()
// Bound concurrent lookups and keep deleted/inaccessible channels exportable by ID.
for (let offset = 0; offset < channelIds.length; offset += 5) {
signal.throwIfAborted()
const batch = channelIds.slice(offset, offset + 5)
const results = await Promise.allSettled(
batch.map((id) =>
getChannel(id, {
signal,
skipBusinessError: true,
skipErrorHandler: true,
})
)
)
signal.throwIfAborted()
results.forEach((result, index) => {
if (
result.status === 'fulfilled' &&
result.value.success &&
result.value.data?.name
) {
channelNames.set(batch[index], result.value.data.name)
}
})
}
return tasks.map((log) => ({
...log,
channel_name: channelNames.get(log.channel_id),
}))
}
return logs
}
export async function buildLogsWorkbook(
logs: ExportLog[],
config: Pick<FetchLogsConfig, 'logCategory' | 'isAdmin'>,
t: TFunction
): Promise<WorkBook> {
const XLSX = await import('xlsx')
let headers: string[]
let rows: (string | number | boolean)[][]
if (config.logCategory === 'common') {
headers = [
t('Time'),
t('Type'),
t('Model'),
t('Token'),
t('Group'),
t('Input Tokens'),
t('Output Tokens'),
t('Cost'),
t('Quota'),
t('Duration (seconds)'),
t('Stream'),
t('Request ID'),
t('Upstream Request ID'),
t('IP Address'),
t('Content'),
]
if (config.isAdmin) headers.push(t('Username'), t('Channel'))
rows = (logs as UsageLog[]).map((log) => {
const row = [
formatTimestampToDate(log.created_at),
t(getLogTypeConfig(log.type).label),
log.model_name,
log.token_name,
log.group,
log.prompt_tokens,
log.completion_tokens,
formatLogQuota(log.quota),
log.quota,
log.use_time,
log.is_stream,
log.request_id,
log.upstream_request_id,
log.ip,
log.content,
]
if (config.isAdmin) {
row.push(log.username, log.channel_name || log.channel)
}
return row
})
} else {
headers = [
t('Task ID'),
t('Submit Time'),
t('Finish Time'),
t('Action'),
t('Status'),
t('Progress'),
t('Fail Reason'),
]
if (config.logCategory === 'drawing') {
headers.push(t('Prompt'))
rows = (logs as MidjourneyLog[]).map((log) => [
log.mj_id,
formatTimestampToDate(log.submit_time, 'milliseconds'),
formatTimestampToDate(log.finish_time, 'milliseconds'),
log.action,
log.status,
log.progress,
log.fail_reason || '',
log.prompt,
])
} else {
headers.push(t('Platform'), t('Model'), t('Group'), t('Cost'), t('Quota'))
rows = (logs as TaskLog[]).map((log) => [
log.task_id,
formatTimestampToDate(log.submit_time),
formatTimestampToDate(log.finish_time),
log.action,
log.status,
log.progress || '',
log.fail_reason || '',
log.platform,
log.properties?.origin_model_name || '',
log.group,
formatLogQuota(log.quota),
log.quota,
])
}
if (config.isAdmin) {
if (config.logCategory === 'task') {
headers.push(
t('User ID'),
t('Username'),
t('Channel ID'),
t('Channel Name')
)
} else {
headers.push(t('User ID'), t('Channel'))
}
rows.forEach((row, index) => {
const log = logs[index] as MidjourneyLog | TaskLog
if (config.logCategory === 'task') {
const task = log as ExportTaskLog
row.push(
task.user_id,
task.username || t('Unknown'),
task.channel_id,
task.channel_name || t('Unknown')
)
} else {
row.push(log.user_id, log.channel_id)
}
})
}
}
// Explicit scalar cells keep untrusted text as text, never spreadsheet formulas.
const sheet = XLSX.utils.aoa_to_sheet([headers, ...rows])
sheet['!cols'] = headers.map(() => ({ wch: 22 }))
sheet['!autofilter'] = { ref: sheet['!ref'] || 'A1' }
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, 'Logs')
return workbook
}
......@@ -911,6 +911,7 @@
"Channel key unlocked": "Channel key unlocked",
"Channel Management": "Channel Management",
"Channel models": "Channel models",
"Channel Name": "Channel Name",
"Channel name is required": "Channel name is required",
"Channel test completed": "Channel test completed",
"Channel test concurrency": "Channel test concurrency",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "Duplicate source model(s): {{models}}",
"Duration": "Duration",
"Duration (hours)": "Duration (hours)",
"Duration (seconds)": "Duration (seconds)",
"Duration Settings": "Duration Settings",
"Duration Unit": "Duration Unit",
"Duration Value": "Duration Value",
......@@ -2065,6 +2067,9 @@
"Expires": "Expires",
"Expires at": "Expires at",
"Expires in": "Expires in",
"Export applied filters to Excel (up to {{count}} rows).": "Export applied filters to Excel (up to {{count}} rows).",
"Export Excel": "Export Excel",
"Exporting {{count}} rows": "Exporting {{count}} rows",
"Expose ratio API": "Expose ratio API",
"Exposes the pricing/models catalog in the top navigation.": "Exposes the pricing/models catalog in the top navigation.",
"Expression": "Expression",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "Failed to enable channels",
"Failed to enable model": "Failed to enable model",
"Failed to enable tag channels": "Failed to enable tag channels",
"Failed to export logs": "Failed to export logs",
"Failed to fetch channel key": "Failed to fetch channel key",
"Failed to fetch checkin status": "Failed to fetch check-in status",
"Failed to fetch deployment details": "Failed to fetch deployment details",
......@@ -2989,6 +2995,7 @@
"Logo": "Logo",
"Logo URL": "Logo URL",
"Logs": "Logs",
"Logs changed during export. Please try again.": "Logs changed during export. Please try again.",
"Long passwords are unavailable until the password storage upgrade is complete.": "Long passwords are unavailable until the password storage upgrade is complete.",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.",
"Low balance": "Low balance",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "Too many files. Some were not added.",
"Too many incorrect codes. Start email verification again.": "Too many incorrect codes. Start email verification again.",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "Too many logs to export. Narrow the filters to {{count}} rows or fewer.",
"Too many requests": "Too many requests",
"Tool / function declarations the model may call": "Tool / function declarations the model may call",
"Tool identifier": "Tool identifier",
......
......@@ -911,6 +911,7 @@
"Channel key unlocked": "Clé de canal déverrouillée",
"Channel Management": "Gestion des canaux",
"Channel models": "Modèles de canaux",
"Channel Name": "Nom du canal",
"Channel name is required": "Le nom du canal est requis",
"Channel test completed": "Test du canal terminé",
"Channel test concurrency": "Parallélisme des tests de canaux",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "Modèle(s) source en double : {{models}}",
"Duration": "Durée",
"Duration (hours)": "Durée (heures)",
"Duration (seconds)": "Durée (secondes)",
"Duration Settings": "Paramètres de durée",
"Duration Unit": "Unité de durée",
"Duration Value": "Valeur de durée",
......@@ -2065,6 +2067,9 @@
"Expires": "Expire",
"Expires at": "Expire le",
"Expires in": "Expire dans",
"Export applied filters to Excel (up to {{count}} rows).": "Exporter les résultats filtrés vers Excel ({{count}} lignes maximum).",
"Export Excel": "Exporter Excel",
"Exporting {{count}} rows": "Export de {{count}} lignes",
"Expose ratio API": "Exposer l'API de ratio",
"Exposes the pricing/models catalog in the top navigation.": "Expose le catalogue des prix/modèles dans la navigation supérieure.",
"Expression": "Expression",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "Échec de l'activation des canaux",
"Failed to enable model": "Échec de l'activation du modèle",
"Failed to enable tag channels": "Échec de l'activation des canaux de tags",
"Failed to export logs": "Échec de l’export des journaux",
"Failed to fetch channel key": "Échec de la récupération de la clé du canal",
"Failed to fetch checkin status": "Échec de la récupération du statut d'enregistrement",
"Failed to fetch deployment details": "Impossible de récupérer les détails du déploiement",
......@@ -2989,6 +2995,7 @@
"Logo": "Logo",
"Logo URL": "URL du logo",
"Logs": "Journaux",
"Logs changed during export. Please try again.": "Les journaux ont changé pendant l’export. Réessayez.",
"Long passwords are unavailable until the password storage upgrade is complete.": "Les mots de passe longs seront disponibles après la mise à niveau du stockage des mots de passe.",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Cherchez une règle de taux spécial correspondant à ce groupe d’utilisateurs et ce groupe de facturation. Si elle existe, utilisez son taux ; sinon le taux de base du groupe de facturation.",
"Low balance": "Solde faible",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "Trop de fichiers. Certains n'ont pas été ajoutés.",
"Too many incorrect codes. Start email verification again.": "Trop de codes incorrects. Recommencez la vérification de l’adresse e-mail.",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Trop de sessions de connexion ont été créées récemment. Attendez la fin de la fenêtre glissante, puis réessayez.",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "Trop de journaux. Affinez les filtres pour obtenir au plus {{count}} lignes.",
"Too many requests": "Trop de requêtes",
"Tool / function declarations the model may call": "Déclarations d'outils / fonctions que le modèle peut appeler",
"Tool identifier": "Identifiant d’outil",
......
......@@ -911,6 +911,7 @@
"Channel key unlocked": "チャネルキーが解除されました",
"Channel Management": "チャネル管理",
"Channel models": "チャネルモデル",
"Channel Name": "チャネル名",
"Channel name is required": "チャネル名が必要です",
"Channel test completed": "チャネルテストが完了しました",
"Channel test concurrency": "チャンネルテストの同時実行数",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "重複したソースモデル: {{models}}",
"Duration": "所要時間",
"Duration (hours)": "期間(時間)",
"Duration (seconds)": "所要時間(秒)",
"Duration Settings": "有効期間設定",
"Duration Unit": "期間単位",
"Duration Value": "期間値",
......@@ -2065,6 +2067,9 @@
"Expires": "有効期限",
"Expires at": "有効期限",
"Expires in": "期限まで",
"Export applied filters to Excel (up to {{count}} rows).": "適用済みフィルターの結果を Excel にエクスポート(最大 {{count}} 件)。",
"Export Excel": "Excel にエクスポート",
"Exporting {{count}} rows": "{{count}} 件をエクスポート中",
"Expose ratio API": "倍率APIを公開",
"Exposes the pricing/models catalog in the top navigation.": "価格/モデルカタログをトップナビゲーションに表示します。",
"Expression": "式",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "チャネルの有効化に失敗しました",
"Failed to enable model": "モデルの有効化に失敗しました",
"Failed to enable tag channels": "タグチャネルの有効化に失敗しました",
"Failed to export logs": "ログのエクスポートに失敗しました",
"Failed to fetch channel key": "チャネルキーの取得に失敗しました",
"Failed to fetch checkin status": "チェックインステータスの取得に失敗しました",
"Failed to fetch deployment details": "デプロイメント詳細の取得に失敗しました",
......@@ -2989,6 +2995,7 @@
"Logo": "ロゴ",
"Logo URL": "ロゴURL",
"Logs": "ログ",
"Logs changed during export. Please try again.": "エクスポート中にログが変更されました。再試行してください。",
"Long passwords are unavailable until the password storage upgrade is complete.": "パスワード保存方式の更新が完了するまで、長いパスワードは使用できません。",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "このユーザーグループと課金グループに一致する特別倍率ルールを探します。あればその倍率を、なければ料金表の課金グループの基本倍率を使います。",
"Low balance": "残高不足",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "ファイルが多すぎます。一部は追加されませんでした。",
"Too many incorrect codes. Start email verification again.": "コードの入力ミスが多すぎます。メール認証をやり直してください。",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "最近作成されたログインセッションが多すぎます。ローリングウィンドウが経過してから、もう一度お試しください。",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "ログが多すぎます。フィルターで {{count}} 件以下に絞ってください。",
"Too many requests": "リクエストが多すぎます",
"Tool / function declarations the model may call": "モデルが呼び出せるツール / 関数の宣言",
"Tool identifier": "ツールID",
......
......@@ -911,6 +911,7 @@
"Channel key unlocked": "Ключ канала разблокирован",
"Channel Management": "Управление каналами",
"Channel models": "Модели каналов",
"Channel Name": "Название канала",
"Channel name is required": "Имя канала обязательно",
"Channel test completed": "Тест канала завершён",
"Channel test concurrency": "Параллельность проверки каналов",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "Повторяющиеся исходные модели: {{models}}",
"Duration": "Длительность",
"Duration (hours)": "Длительность (часы)",
"Duration (seconds)": "Длительность (секунды)",
"Duration Settings": "Настройки срока действия",
"Duration Unit": "Единица срока",
"Duration Value": "Значение срока",
......@@ -2065,6 +2067,9 @@
"Expires": "Истекает",
"Expires at": "Истекает",
"Expires in": "До истечения",
"Export applied filters to Excel (up to {{count}} rows).": "Экспорт результатов фильтрации в Excel (до {{count}} строк).",
"Export Excel": "Экспорт в Excel",
"Exporting {{count}} rows": "Экспорт {{count}} строк",
"Expose ratio API": "Интерфейс экспонирования коэффициента",
"Exposes the pricing/models catalog in the top navigation.": "Отображает каталог цен/моделей в верхней навигации.",
"Expression": "Выражение",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "Не удалось включить каналы",
"Failed to enable model": "Не удалось включить модель",
"Failed to enable tag channels": "Не удалось включить каналы тегов",
"Failed to export logs": "Не удалось экспортировать журналы",
"Failed to fetch channel key": "Не удалось получить ключ канала",
"Failed to fetch checkin status": "Не удалось получить статус регистрации",
"Failed to fetch deployment details": "Не удалось получить сведения о развертывании",
......@@ -2989,6 +2995,7 @@
"Logo": "Логотип",
"Logo URL": "URL логотипа",
"Logs": "Журналы",
"Logs changed during export. Please try again.": "Журналы изменились во время экспорта. Повторите попытку.",
"Long passwords are unavailable until the password storage upgrade is complete.": "Длинные пароли будут доступны после обновления системы хранения паролей.",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Найдите правило особого коэффициента для этой группы пользователя и тарифной группы. Если оно есть — используется его коэффициент, иначе базовый коэффициент тарифной группы.",
"Low balance": "Низкий баланс",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "Слишком много файлов. Некоторые не были добавлены.",
"Too many incorrect codes. Start email verification again.": "Слишком много неверных кодов. Начните подтверждение почты заново.",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "За последнее время создано слишком много сеансов входа. Дождитесь окончания скользящего временного окна и повторите попытку.",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "Слишком много записей. Уточните фильтры до {{count}} строк или менее.",
"Too many requests": "Слишком много запросов",
"Tool / function declarations the model may call": "Объявления инструментов и функций, которые модель может вызывать",
"Tool identifier": "Идентификатор инструмента",
......
......@@ -911,6 +911,7 @@
"Channel key unlocked": "Khóa kênh đã được mở khóa",
"Channel Management": "Quản lý kênh",
"Channel models": "Mô hình kênh",
"Channel Name": "Tên kênh",
"Channel name is required": "Tên kênh là bắt buộc",
"Channel test completed": "Kiểm tra kênh hoàn tất",
"Channel test concurrency": "Mức đồng thời khi kiểm tra kênh",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "Mô hình nguồn trùng lặp: {{models}}",
"Duration": "Thời lượng",
"Duration (hours)": "Thời lượng (giờ)",
"Duration (seconds)": "Thời lượng (giây)",
"Duration Settings": "Cài đặt thời lượng",
"Duration Unit": "Đơn vị thời lượng",
"Duration Value": "Giá trị thời lượng",
......@@ -2065,6 +2067,9 @@
"Expires": "Hết hạn",
"Expires at": "Hết hạn lúc",
"Expires in": "Còn lại",
"Export applied filters to Excel (up to {{count}} rows).": "Xuất kết quả đã lọc sang Excel (tối đa {{count}} dòng).",
"Export Excel": "Xuất Excel",
"Exporting {{count}} rows": "Đang xuất {{count}} dòng",
"Expose ratio API": "Cung cấp API tỷ lệ",
"Exposes the pricing/models catalog in the top navigation.": "Hiển thị danh mục giá/mô hình trên thanh điều hướng đầu trang.",
"Expression": "Biểu thức",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "Không thể kích hoạt các kênh",
"Failed to enable model": "Không thể kích hoạt mô hình",
"Failed to enable tag channels": "Không thể kích hoạt kênh thẻ",
"Failed to export logs": "Không thể xuất nhật ký",
"Failed to fetch channel key": "Không thể lấy khóa kênh",
"Failed to fetch checkin status": "Không thể tải trạng thái điểm danh",
"Failed to fetch deployment details": "Không thể lấy chi tiết triển khai",
......@@ -2989,6 +2995,7 @@
"Logo": "Logo",
"Logo URL": "URL Logo",
"Logs": "Nhật ký",
"Logs changed during export. Please try again.": "Nhật ký đã thay đổi khi xuất. Vui lòng thử lại.",
"Long passwords are unavailable until the password storage upgrade is complete.": "Mật khẩu dài chỉ khả dụng sau khi hoàn tất nâng cấp hệ thống lưu trữ mật khẩu.",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Tìm quy tắc hệ số đặc biệt khớp với nhóm người dùng và nhóm tính phí này. Nếu có thì dùng hệ số của quy tắc, nếu không thì dùng hệ số cơ bản của nhóm tính phí trong bảng định giá.",
"Low balance": "Số dư thấp",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "Quá nhiều tệp. Một số không được thêm.",
"Too many incorrect codes. Start email verification again.": "Nhập sai mã quá nhiều lần. Vui lòng bắt đầu xác minh email lại.",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Gần đây đã tạo quá nhiều phiên đăng nhập. Vui lòng chờ cửa sổ thời gian trượt kết thúc rồi thử lại.",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "Quá nhiều nhật ký. Hãy lọc còn tối đa {{count}} dòng.",
"Too many requests": "Quá nhiều yêu cầu",
"Tool / function declarations the model may call": "Khai báo công cụ / hàm mà model có thể gọi",
"Tool identifier": "Định danh công cụ",
......
......@@ -911,6 +911,7 @@
"Channel key unlocked": "渠道金鑰已解鎖",
"Channel Management": "渠道管理",
"Channel models": "渠道模型",
"Channel Name": "渠道名稱",
"Channel name is required": "渠道名稱是必填的",
"Channel test completed": "渠道測試完成",
"Channel test concurrency": "渠道測試並行數",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "重複的源模型:{{models}}",
"Duration": "耗時",
"Duration (hours)": "時長 (小時)",
"Duration (seconds)": "耗時(秒)",
"Duration Settings": "有效期設定",
"Duration Unit": "有效期單位",
"Duration Value": "有效期數值",
......@@ -2065,6 +2067,9 @@
"Expires": "過期",
"Expires at": "到期時間",
"Expires in": "剩餘到期時間",
"Export applied filters to Excel (up to {{count}} rows).": "將目前已套用篩選條件的結果匯出為 Excel(最多 {{count}} 筆)。",
"Export Excel": "匯出 Excel",
"Exporting {{count}} rows": "正在匯出 {{count}} 筆",
"Expose ratio API": "暴露倍率接口",
"Exposes the pricing/models catalog in the top navigation.": "在頂部導航中顯示定價/模型目錄。",
"Expression": "表達式",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "啟用渠道失敗",
"Failed to enable model": "啟用模型失敗",
"Failed to enable tag channels": "啟用標籤渠道失敗",
"Failed to export logs": "匯出日誌失敗",
"Failed to fetch channel key": "獲取渠道金鑰失敗",
"Failed to fetch checkin status": "獲取簽到狀態失敗",
"Failed to fetch deployment details": "獲取部署詳情失敗",
......@@ -2989,6 +2995,7 @@
"Logo": "徽標",
"Logo URL": "徽標 URL",
"Logs": "日誌",
"Logs changed during export. Please try again.": "匯出期間日誌發生變更,請重試。",
"Long passwords are unavailable until the password storage upgrade is complete.": "密碼儲存升級完成前暫不支援長密碼。",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "查找匹配「該用戶分組 + 該收費分組」的特殊倍率規則。有就用規則裡的倍率,沒有就用定價分組表中收費分組的基礎倍率。",
"Low balance": "餘額偏低",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "檔案過多。部分未添加。",
"Too many incorrect codes. Start email verification again.": "驗證碼錯誤次數過多,請重新開始信箱驗證。",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "近期建立的登入工作階段過多。請等待滾動時間窗口結束後再試。",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "日誌過多,請縮小篩選範圍至 {{count}} 筆以內再匯出。",
"Too many requests": "請求過於頻繁",
"Tool / function declarations the model may call": "模型可呼叫的工具 / 函數聲明",
"Tool identifier": "工具標識",
......
......@@ -911,6 +911,7 @@
"Channel key unlocked": "渠道密钥已解锁",
"Channel Management": "渠道管理",
"Channel models": "渠道模型",
"Channel Name": "渠道名称",
"Channel name is required": "渠道名称是必填的",
"Channel test completed": "渠道测试完成",
"Channel test concurrency": "渠道测试并发数",
......@@ -1725,6 +1726,7 @@
"Duplicate source model(s): {{models}}": "重复的源模型:{{models}}",
"Duration": "耗时",
"Duration (hours)": "时长 (小时)",
"Duration (seconds)": "耗时(秒)",
"Duration Settings": "有效期设置",
"Duration Unit": "有效期单位",
"Duration Value": "有效期数值",
......@@ -2065,6 +2067,9 @@
"Expires": "过期",
"Expires at": "到期时间",
"Expires in": "剩余到期时间",
"Export applied filters to Excel (up to {{count}} rows).": "将当前已应用筛选条件的结果导出为 Excel(最多 {{count}} 条)。",
"Export Excel": "导出 Excel",
"Exporting {{count}} rows": "正在导出 {{count}} 条",
"Expose ratio API": "暴露倍率接口",
"Exposes the pricing/models catalog in the top navigation.": "在顶部导航中显示定价/模型目录。",
"Expression": "表达式",
......@@ -2149,6 +2154,7 @@
"Failed to enable channels": "启用渠道失败",
"Failed to enable model": "启用模型失败",
"Failed to enable tag channels": "启用标签渠道失败",
"Failed to export logs": "导出日志失败",
"Failed to fetch channel key": "获取渠道密钥失败",
"Failed to fetch checkin status": "获取签到状态失败",
"Failed to fetch deployment details": "获取部署详情失败",
......@@ -2989,6 +2995,7 @@
"Logo": "徽标",
"Logo URL": "徽标 URL",
"Logs": "日志",
"Logs changed during export. Please try again.": "导出期间日志发生变化,请重试。",
"Long passwords are unavailable until the password storage upgrade is complete.": "密码存储升级完成前暂不支持长密码。",
"Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "查找匹配「该用户分组 + 该计费分组」的特殊倍率规则。有就用规则里的倍率,没有就用定价分组表中计费分组的基础倍率。",
"Low balance": "余额偏低",
......@@ -5571,6 +5578,7 @@
"Too many files. Some were not added.": "文件过多。部分未添加。",
"Too many incorrect codes. Start email verification again.": "验证码错误次数过多,请重新开始邮箱验证。",
"Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "近期创建的登录会话过多。请等待滚动时间窗口结束后再试。",
"Too many logs to export. Narrow the filters to {{count}} rows or fewer.": "日志过多,请缩小筛选范围至 {{count}} 条以内再导出。",
"Too many requests": "请求过于频繁",
"Tool / function declarations the model may call": "模型可调用的工具 / 函数声明",
"Tool identifier": "工具标识",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment