Parcourir la source

🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs

Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system.

- **Model deployments (io.net)**
  - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`)
  - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration
  - Improve create-deployment flow (proper request shape, name availability check, price estimation parity)

- **System settings**
  - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance

- **Channels / Ollama**
  - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation
  - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib`

- **Quality**
  - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration
t0ng7u il y a 3 mois
Parent
commit
e25868853d
47 fichiers modifiés avec 6695 ajouts et 74 suppressions
  1. 1 0
      web/package.json
  2. 186 0
      web/scripts/sync-i18n.mjs
  3. 3 2
      web/src/components/layout/components/main.tsx
  4. 11 0
      web/src/features/channels/api.ts
  5. 75 1
      web/src/features/channels/components/channels-columns.tsx
  6. 7 0
      web/src/features/channels/components/channels-dialogs.tsx
  7. 1 0
      web/src/features/channels/components/channels-provider.tsx
  8. 16 0
      web/src/features/channels/components/data-table-row-actions.tsx
  9. 586 0
      web/src/features/channels/components/dialogs/ollama-models-dialog.tsx
  10. 260 17
      web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  11. 54 0
      web/src/features/channels/lib/channel-form.ts
  12. 143 0
      web/src/features/channels/lib/ollama-utils.ts
  13. 4 0
      web/src/features/channels/types.ts
  14. 346 0
      web/src/features/models/api.ts
  15. 122 0
      web/src/features/models/components/deployment-access-guard.tsx
  16. 303 0
      web/src/features/models/components/deployments-columns.tsx
  17. 401 0
      web/src/features/models/components/deployments-table.tsx
  18. 723 0
      web/src/features/models/components/dialogs/create-deployment-dialog.tsx
  19. 218 0
      web/src/features/models/components/dialogs/extend-deployment-dialog.tsx
  20. 130 0
      web/src/features/models/components/dialogs/rename-deployment-dialog.tsx
  21. 408 0
      web/src/features/models/components/dialogs/update-config-dialog.tsx
  22. 255 0
      web/src/features/models/components/dialogs/view-details-dialog.tsx
  23. 268 0
      web/src/features/models/components/dialogs/view-logs-dialog.tsx
  24. 6 0
      web/src/features/models/components/models-provider.tsx
  25. 29 0
      web/src/features/models/components/models-tabs.tsx
  26. 43 0
      web/src/features/models/constants.ts
  27. 89 0
      web/src/features/models/hooks/use-model-deployment-settings.ts
  28. 86 7
      web/src/features/models/index.tsx
  29. 24 0
      web/src/features/models/lib/deployments-utils.ts
  30. 16 0
      web/src/features/models/lib/query-keys.ts
  31. 89 0
      web/src/features/models/types.ts
  32. 29 0
      web/src/features/profile/api.ts
  33. 470 0
      web/src/features/profile/components/checkin-calendar-card.tsx
  34. 23 5
      web/src/features/profile/index.tsx
  35. 48 0
      web/src/features/profile/types.ts
  36. 187 0
      web/src/features/system-settings/general/checkin-settings-section.tsx
  37. 12 0
      web/src/features/system-settings/general/index.tsx
  38. 10 0
      web/src/features/system-settings/integrations/index.tsx
  39. 252 0
      web/src/features/system-settings/integrations/ionet-deployment-settings-section.tsx
  40. 5 0
      web/src/features/system-settings/types.ts
  41. 119 1
      web/src/i18n/locales/en.json
  42. 120 2
      web/src/i18n/locales/fr.json
  43. 139 21
      web/src/i18n/locales/ja.json
  44. 126 8
      web/src/i18n/locales/ru.json
  45. 127 9
      web/src/i18n/locales/vi.json
  46. 119 1
      web/src/i18n/locales/zh.json
  47. 6 0
      web/src/routes/_authenticated/models/index.tsx

+ 1 - 0
web/package.json

@@ -12,6 +12,7 @@
     "preview": "vite preview",
     "format:check": "prettier --check .",
     "format": "prettier --write .",
+    "i18n:sync": "node scripts/sync-i18n.mjs",
     "knip": "knip"
   },
   "dependencies": {

+ 186 - 0
web/scripts/sync-i18n.mjs

@@ -0,0 +1,186 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+// This script is executed from the web/ package root (see package.json script).
+const LOCALES_DIR = path.resolve('src/i18n/locales')
+const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only
+
+function isPlainObject(v) {
+  return typeof v === 'object' && v !== null && !Array.isArray(v)
+}
+
+function stableStringify(obj) {
+  return JSON.stringify(obj, null, 2) + '\n'
+}
+
+function countLeafKeys(obj) {
+  if (Array.isArray(obj)) return obj.length
+  if (!isPlainObject(obj)) return 0
+  let count = 0
+  for (const k of Object.keys(obj)) {
+    const v = obj[k]
+    if (isPlainObject(v) || Array.isArray(v)) count += countLeafKeys(v)
+    else count += 1
+  }
+  return count
+}
+
+function reorderLikeBase(base, target, fill, extras, missing, currentPath = []) {
+  // If base is an object, we keep base's key order and recurse.
+  if (isPlainObject(base)) {
+    const out = {}
+    const t = isPlainObject(target) ? target : {}
+    const f = isPlainObject(fill) ? fill : {}
+
+    for (const key of Object.keys(base)) {
+      const nextPath = [...currentPath, key]
+      if (Object.prototype.hasOwnProperty.call(t, key)) {
+        out[key] = reorderLikeBase(base[key], t[key], f[key], extras, missing, nextPath)
+      } else {
+        missing.push(nextPath.join('.'))
+        out[key] = reorderLikeBase(base[key], undefined, f[key], extras, missing, nextPath)
+      }
+    }
+
+    for (const key of Object.keys(t)) {
+      if (!Object.prototype.hasOwnProperty.call(base, key)) {
+        const nextPath = [...currentPath, key].join('.')
+        extras[nextPath] = t[key]
+      }
+    }
+
+    return out
+  }
+
+  // For arrays: prefer target if it's also an array; otherwise use base.
+  if (Array.isArray(base)) {
+    if (Array.isArray(target)) return target
+    if (Array.isArray(fill)) return fill
+    return base
+  }
+
+  // For primitives: prefer target if defined, else base.
+  return target === undefined ? (fill ?? base) : target
+}
+
+function isLikelyUntranslated({ locale, baseValue, value }) {
+  if (typeof value !== 'string' || typeof baseValue !== 'string') return false
+  if (value !== baseValue) return false
+
+  // Skip short tokens / acronyms / ids
+  const s = baseValue.trim()
+  if (s.length < 6) return false
+  if (!/[A-Za-z]{3,}/.test(s)) return false
+
+  // For locales with non-latin scripts, equality with EN is a strong signal.
+  if (locale === 'ja' || locale === 'zh') return true
+  if (locale === 'ru') return true
+
+  // For fr/vi: still useful but noisier; keep it conservative.
+  if (locale === 'fr' || locale === 'vi') return /\b(the|and|or|to|with|please)\b/i.test(s)
+
+  return false
+}
+
+async function main() {
+  const entries = await fs.readdir(LOCALES_DIR, { withFileTypes: true })
+  const localeFiles = entries
+    .filter((e) => e.isFile() && e.name.endsWith('.json'))
+    .map((e) => e.name)
+    .sort((a, b) => a.localeCompare(b))
+
+  // Auto-pick base locale as the one with the most leaf keys under translation (most "rich").
+  const parsedByLocale = {}
+  for (const filename of localeFiles) {
+    const locale = filename.replace(/\.json$/i, '')
+    const raw = await fs.readFile(path.join(LOCALES_DIR, filename), 'utf8')
+    parsedByLocale[locale] = JSON.parse(raw)
+  }
+
+  const baseLocale = Object.keys(parsedByLocale)
+    .map((locale) => {
+      const json = parsedByLocale[locale]
+      const trans = json?.translation ?? {}
+      return { locale, score: countLeafKeys(trans) }
+    })
+    .sort((a, b) => b.score - a.score || a.locale.localeCompare(b.locale))[0]?.locale
+
+  if (!baseLocale) throw new Error('No locale files found.')
+
+  const baseFile = `${baseLocale}.json`
+  const baseJson = parsedByLocale[baseLocale]
+
+  const compareJson = parsedByLocale[FALLBACK_COMPARE_LOCALE] ?? baseJson
+
+  const report = {
+    base: baseFile,
+    locales: {},
+  }
+
+  const extrasDir = path.join(LOCALES_DIR, '_extras')
+  const reportsDir = path.join(LOCALES_DIR, '_reports')
+  await fs.mkdir(extrasDir, { recursive: true })
+  await fs.mkdir(reportsDir, { recursive: true })
+
+  for (const filename of localeFiles) {
+    const locale = filename.replace(/\.json$/i, '')
+    const full = path.join(LOCALES_DIR, filename)
+    const json = parsedByLocale[locale]
+
+    const extras = {}
+    const missing = []
+    const fixed = reorderLikeBase(baseJson, json, compareJson, extras, missing)
+
+    // Untranslated scan (translation namespace only)
+    const untranslated = {}
+    const compareTrans = compareJson?.translation ?? {}
+    const trans = fixed?.translation ?? {}
+    if (
+      isPlainObject(compareTrans) &&
+      isPlainObject(trans) &&
+      locale !== FALLBACK_COMPARE_LOCALE &&
+      locale !== baseLocale
+    ) {
+      for (const k of Object.keys(compareTrans)) {
+        const baseValue = compareTrans[k]
+        const value = trans[k]
+        if (isLikelyUntranslated({ locale, baseValue, value })) {
+          untranslated[k] = value
+        }
+      }
+    }
+
+    report.locales[locale] = {
+      file: filename,
+      missingCount: missing.length,
+      extrasCount: Object.keys(extras).length,
+      untranslatedCount: Object.keys(untranslated).length,
+    }
+
+    if (Object.keys(extras).length > 0) {
+      await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8')
+    }
+    if (Object.keys(untranslated).length > 0) {
+      await fs.writeFile(
+        path.join(reportsDir, `${locale}.untranslated.json`),
+        stableStringify(untranslated),
+        'utf8',
+      )
+    }
+
+    // Rewrite locale file in base order (even for en to normalize formatting)
+    await fs.writeFile(full, stableStringify(fixed), 'utf8')
+  }
+
+  await fs.writeFile(path.join(reportsDir, '_sync-report.json'), stableStringify(report), 'utf8')
+  // eslint-disable-next-line no-console
+  console.log(`i18n sync done. Report: ${path.join(reportsDir, '_sync-report.json')}`)
+}
+
+main().catch((err) => {
+  // eslint-disable-next-line no-console
+  console.error(err)
+  process.exitCode = 1
+})
+
+

+ 3 - 2
web/src/components/layout/components/main.tsx

@@ -7,6 +7,7 @@ type MainProps = React.HTMLAttributes<HTMLElement> & {
   fixed?: boolean
   /**
    * 是否使用流式布局(不限制最大宽度)
+   * - 默认开启(true)
    */
   fluid?: boolean
 }
@@ -14,9 +15,9 @@ type MainProps = React.HTMLAttributes<HTMLElement> & {
 /**
  * Main 内容区域组件
  * - fixed=true 时会使用 flexbox 布局并防止内容溢出
- * - fluid=true 时不会限制最大宽度
+ * - fluid=true 时不会限制最大宽度(默认)
  */
-export function Main({ fixed, className, fluid, ...props }: MainProps) {
+export function Main({ fixed, className, fluid = true, ...props }: MainProps) {
   return (
     <main
       data-layout={fixed ? 'fixed' : 'auto'}

+ 11 - 0
web/src/features/channels/api.ts

@@ -356,6 +356,17 @@ export async function fetchModels(data: {
   return res.data
 }
 
+/**
+ * Delete an Ollama model from a channel
+ */
+export async function deleteOllamaModel(params: {
+  channel_id: number
+  model_name: string
+}): Promise<{ success: boolean; message?: string }> {
+  const res = await api.delete('/api/channel/ollama/delete', { data: params })
+  return res.data
+}
+
 /**
  * Test all enabled channels
  */

+ 75 - 1
web/src/features/channels/components/channels-columns.tsx

@@ -43,6 +43,22 @@ import { DataTableRowActions } from './data-table-row-actions'
 import { DataTableTagRowActions } from './data-table-tag-row-actions'
 import { NumericSpinnerInput } from './numeric-spinner-input'
 
+function parseIonetMeta(otherInfo: string | null | undefined): null | {
+  source?: string
+  deployment_id?: string
+} {
+  if (!otherInfo) return null
+  try {
+    const parsed = JSON.parse(otherInfo)
+    if (parsed && typeof parsed === 'object') {
+      return parsed
+    }
+  } catch {
+    return null
+  }
+  return null
+}
+
 /**
  * Render limited items with "and X more" indicator
  */
@@ -434,6 +450,13 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
             ? 'Multi-key: Random rotation'
             : 'Multi-key: Polling rotation'
 
+        const ionetMeta = parseIonetMeta(channel.other_info)
+        const isIonet = ionetMeta?.source === 'ionet'
+        const deploymentId =
+          typeof ionetMeta?.deployment_id === 'string'
+            ? ionetMeta?.deployment_id
+            : undefined
+
         return (
           <div className='flex items-center gap-2'>
             <div className='flex items-center gap-1.5'>
@@ -459,6 +482,45 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
               size='sm'
               copyable={false}
             />
+            {isIonet && (
+              <TooltipProvider delayDuration={100}>
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <span
+                      className='cursor-pointer'
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        if (!deploymentId) return
+                        const targetUrl = `/console/deployment?deployment_id=${deploymentId}`
+                        window.open(targetUrl, '_blank', 'noopener')
+                      }}
+                    >
+                      <StatusBadge
+                        label='IO.NET'
+                        variant='purple'
+                        size='sm'
+                        copyable={false}
+                      />
+                    </span>
+                  </TooltipTrigger>
+                  <TooltipContent side='top'>
+                    <div className='max-w-xs space-y-1'>
+                      <div className='text-xs'>
+                        {t('From IO.NET deployment')}
+                      </div>
+                      {deploymentId && (
+                        <div className='text-muted-foreground font-mono text-xs'>
+                          {t('Deployment ID')}: {deploymentId}
+                        </div>
+                      )}
+                      <div className='text-muted-foreground text-xs'>
+                        {t('Click to open deployment')}
+                      </div>
+                    </div>
+                  </TooltipContent>
+                </Tooltip>
+              </TooltipProvider>
+            )}
           </div>
         )
       },
@@ -478,6 +540,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
       cell: ({ row }) => {
         const isTagRow = (row.original as any).children !== undefined
         const status = row.getValue('status') as number
+        const channel = row.original as Channel
 
         // Tag row: show aggregated status
         if (isTagRow) {
@@ -511,9 +574,20 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
           CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
           CHANNEL_STATUS_CONFIG[0]
 
+        const isMultiKey = isMultiKeyChannel(channel)
+        const keySize = channel.channel_info?.multi_key_size ?? 0
+        const disabledCount = channel.channel_info?.multi_key_status_list
+          ? Object.keys(channel.channel_info.multi_key_status_list).length
+          : 0
+        const enabledCount = Math.max(0, keySize - disabledCount)
+        const label =
+          isMultiKey && keySize > 0
+            ? `${config.label} (${enabledCount}/${keySize})`
+            : config.label
+
         return (
           <StatusBadge
-            label={config.label}
+            label={label}
             variant={config.variant}
             showDot={config.showDot}
             size='sm'

+ 7 - 0
web/src/features/channels/components/channels-dialogs.tsx

@@ -5,6 +5,7 @@ import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
 import { EditTagDialog } from './dialogs/edit-tag-dialog'
 import { FetchModelsDialog } from './dialogs/fetch-models-dialog'
 import { MultiKeyManageDialog } from './dialogs/multi-key-manage-dialog'
+import { OllamaModelsDialog } from './dialogs/ollama-models-dialog'
 import { TagBatchEditDialog } from './dialogs/tag-batch-edit-dialog'
 import { ChannelMutateDrawer } from './drawers/channel-mutate-drawer'
 
@@ -38,6 +39,12 @@ export function ChannelsDialogs() {
         onOpenChange={(v) => !v && setOpen(null)}
       />
 
+      {/* Ollama Models Dialog */}
+      <OllamaModelsDialog
+        open={open === 'ollama-models'}
+        onOpenChange={(v) => !v && setOpen(null)}
+      />
+
       {/* Copy Channel Dialog */}
       <CopyChannelDialog
         open={open === 'copy-channel'}

+ 1 - 0
web/src/features/channels/components/channels-provider.tsx

@@ -11,6 +11,7 @@ type DialogType =
   | 'test-channel'
   | 'balance-query'
   | 'fetch-models'
+  | 'ollama-models'
   | 'multi-key-manage'
   | 'tag-batch-edit'
   | 'edit-tag'

+ 16 - 0
web/src/features/channels/components/data-table-row-actions.tsx

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
 import { type Row } from '@tanstack/react-table'
 import {
   MoreHorizontal,
+  Boxes,
   Pencil,
   TestTube,
   DollarSign,
@@ -67,6 +68,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
     setOpen('fetch-models')
   }
 
+  const handleManageOllamaModels = () => {
+    setCurrentRow(channel)
+    setOpen('ollama-models')
+  }
+
   const handleCopy = () => {
     setCurrentRow(channel)
     setOpen('copy-channel')
@@ -125,6 +131,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
           </DropdownMenuShortcut>
         </DropdownMenuItem>
 
+        {/* Ollama Models (only for Ollama channels) */}
+        {channel.type === 4 && (
+          <DropdownMenuItem onClick={handleManageOllamaModels}>
+            {t('Manage Ollama Models')}
+            <DropdownMenuShortcut>
+              <Boxes size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
+
         <DropdownMenuSeparator />
 
         {/* Copy Channel */}

+ 586 - 0
web/src/features/channels/components/dialogs/ollama-models-dialog.tsx

@@ -0,0 +1,586 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { Loader2, RefreshCw, Trash2, Download, Search } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { getCommonHeaders } from '@/lib/api'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Progress } from '@/components/ui/progress'
+import { Separator } from '@/components/ui/separator'
+import {
+  deleteOllamaModel,
+  fetchModels as fetchModelsFromEndpoint,
+  fetchUpstreamModels,
+  updateChannel,
+} from '../../api'
+import { channelsQueryKeys, parseModelsString } from '../../lib'
+import {
+  formatBytes,
+  normalizeOllamaModels,
+  resolveOllamaBaseUrl,
+} from '../../lib/ollama-utils'
+import type { OllamaModel, PullProgress } from '../../lib/ollama-utils'
+import { useChannels } from '../channels-provider'
+
+const CHANNEL_TYPE_OLLAMA = 4
+
+export function OllamaModelsDialog({
+  open,
+  onOpenChange,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}) {
+  const { t } = useTranslation()
+  const queryClient = useQueryClient()
+  const { currentRow } = useChannels()
+
+  const isOllamaChannel = currentRow?.type === CHANNEL_TYPE_OLLAMA
+  const channelId = currentRow?.id
+
+  const [isFetching, setIsFetching] = useState(false)
+  const [models, setModels] = useState<OllamaModel[]>([])
+  const [selected, setSelected] = useState<string[]>([])
+  const [search, setSearch] = useState('')
+
+  const [pullName, setPullName] = useState('')
+  const [isPulling, setIsPulling] = useState(false)
+  const [pullProgress, setPullProgress] = useState<PullProgress | null>(null)
+  const pullAbortRef = useRef<AbortController | null>(null)
+
+  const [deleteOpen, setDeleteOpen] = useState(false)
+  const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
+  const [isDeleting, setIsDeleting] = useState(false)
+
+  const filteredModels = useMemo(() => {
+    if (!search.trim()) return models
+    const keyword = search.trim().toLowerCase()
+    return models.filter((m) => m.id.toLowerCase().includes(keyword))
+  }, [models, search])
+
+  const existingModels = useMemo(
+    () => parseModelsString(currentRow?.models ?? ''),
+    [currentRow?.models]
+  )
+
+  useEffect(() => {
+    if (!open) {
+      setModels([])
+      setSelected([])
+      setSearch('')
+      setPullName('')
+      setIsPulling(false)
+      setPullProgress(null)
+      pullAbortRef.current?.abort()
+      pullAbortRef.current = null
+      return
+    }
+
+    if (open && isOllamaChannel && channelId) {
+      void fetchOllamaModels()
+    }
+  }, [open, isOllamaChannel, channelId])
+
+  const fetchOllamaModels = useCallback(async () => {
+    if (!channelId) return
+    setIsFetching(true)
+    try {
+      let normalized: OllamaModel[] = []
+      let lastErr = ''
+
+      // 1) Prefer live fetch for Ollama if base_url is set (more accurate / supports unsaved changes)
+      const baseUrl = resolveOllamaBaseUrl(currentRow ?? null)
+      if (isOllamaChannel && baseUrl) {
+        try {
+          const payloadLive = await fetchModelsFromEndpoint({
+            base_url: baseUrl,
+            type: CHANNEL_TYPE_OLLAMA,
+            key: typeof currentRow?.key === 'string' ? currentRow.key : '',
+          })
+          if (payloadLive?.success) {
+            normalized = normalizeOllamaModels(payloadLive.data)
+          } else if (payloadLive?.message) {
+            lastErr = String(payloadLive.message)
+          }
+        } catch (err: unknown) {
+          lastErr = err instanceof Error ? err.message : ''
+        }
+      }
+
+      // 2) Fallback to server-side fetch by channelId
+      if (!normalized.length) {
+        const payload = await fetchUpstreamModels(Number(channelId))
+        if (payload?.success) {
+          normalized = normalizeOllamaModels(payload.data)
+          lastErr = ''
+        } else {
+          lastErr = String(payload?.message || '')
+        }
+      }
+
+      if (!normalized.length && lastErr) {
+        toast.error(lastErr || t('Failed to fetch models'))
+      }
+
+      setModels(normalized)
+      setSelected((prev) => {
+        if (!prev.length) return normalized.map((m) => m.id)
+        const stillAvailable = prev.filter((id) =>
+          normalized.some((m) => m.id === id)
+        )
+        return stillAvailable.length
+          ? stillAvailable
+          : normalized.map((m) => m.id)
+      })
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : undefined
+      toast.error(msg || t('Failed to fetch models'))
+      setModels([])
+    } finally {
+      setIsFetching(false)
+    }
+  }, [channelId, currentRow, isOllamaChannel, t])
+
+  const toggleSelected = (modelId: string, checked: boolean) => {
+    setSelected((prev) => {
+      if (checked) return prev.includes(modelId) ? prev : [...prev, modelId]
+      return prev.filter((id) => id !== modelId)
+    })
+  }
+
+  const selectAllFiltered = () => {
+    setSelected((prev) => {
+      const next = new Set(prev)
+      filteredModels.forEach((m) => next.add(m.id))
+      return Array.from(next)
+    })
+  }
+
+  const clearSelection = () => setSelected([])
+
+  const applySelection = async (mode: 'append' | 'replace') => {
+    if (!currentRow) return
+    if (!selected.length) {
+      toast.info(t('No models selected'))
+      return
+    }
+
+    const next =
+      mode === 'replace'
+        ? Array.from(new Set(selected))
+        : Array.from(new Set([...existingModels, ...selected]))
+
+    const res = await updateChannel(currentRow.id, { models: next.join(',') })
+    if (res.success) {
+      toast.success(
+        mode === 'replace'
+          ? t('Models updated successfully')
+          : t('Models appended successfully')
+      )
+      queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+    }
+  }
+
+  const pullModel = async () => {
+    if (!channelId) return
+    if (!pullName.trim()) {
+      toast.error(t('Please enter model name'))
+      return
+    }
+
+    if (!resolveOllamaBaseUrl(currentRow)) {
+      toast.error(t('Please set Ollama API Base URL first'))
+      return
+    }
+
+    pullAbortRef.current?.abort()
+    const controller = new AbortController()
+    pullAbortRef.current = controller
+
+    setIsPulling(true)
+    setPullProgress({ status: 'starting', completed: 0, total: 0 })
+
+    try {
+      const response = await fetch('/api/channel/ollama/pull/stream', {
+        method: 'POST',
+        credentials: 'include',
+        headers: {
+          ...getCommonHeaders(),
+          Accept: 'text/event-stream',
+        },
+        body: JSON.stringify({
+          channel_id: channelId,
+          model_name: pullName.trim(),
+        }),
+        signal: controller.signal,
+      })
+
+      if (!response.ok || !response.body) {
+        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+      }
+
+      const reader = response.body.getReader()
+      const decoder = new TextDecoder()
+      let buffer = ''
+
+      while (true) {
+        const { done, value } = await reader.read()
+        if (done) break
+
+        buffer += decoder.decode(value, { stream: true })
+        const lines = buffer.split('\n')
+        buffer = lines.pop() || ''
+
+        for (const line of lines) {
+          if (!line.startsWith('data: ')) continue
+          const eventData = line.slice(6)
+          if (!eventData) continue
+
+          if (eventData === '[DONE]') {
+            setIsPulling(false)
+            setPullProgress(null)
+            pullAbortRef.current = null
+            return
+          }
+
+          try {
+            const data = JSON.parse(eventData)
+            if (data?.status) {
+              setPullProgress(data)
+            } else if (data?.error) {
+              toast.error(String(data.error))
+              setIsPulling(false)
+              setPullProgress(null)
+              pullAbortRef.current = null
+              return
+            } else if (data?.message) {
+              toast.success(String(data.message))
+              setPullName('')
+              setIsPulling(false)
+              setPullProgress(null)
+              pullAbortRef.current = null
+              await fetchOllamaModels()
+              queryClient.invalidateQueries({
+                queryKey: channelsQueryKeys.lists(),
+              })
+              return
+            }
+          } catch {
+            // ignore malformed events
+          }
+        }
+      }
+
+      setIsPulling(false)
+      setPullProgress(null)
+      pullAbortRef.current = null
+      await fetchOllamaModels()
+      queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+    } catch (err: unknown) {
+      const isAbort =
+        typeof err === 'object' &&
+        err !== null &&
+        'name' in err &&
+        (err as { name?: unknown }).name === 'AbortError'
+      if (!isAbort) {
+        const msg = err instanceof Error ? err.message : ''
+        toast.error(t('Model pull failed: {{msg}}', { msg }))
+      }
+      setIsPulling(false)
+      setPullProgress(null)
+      pullAbortRef.current = null
+    }
+  }
+
+  const deleteModel = async (modelName: string) => {
+    if (!channelId) return
+    try {
+      setIsDeleting(true)
+      const payload = await deleteOllamaModel({
+        channel_id: Number(channelId),
+        model_name: modelName,
+      })
+      if (payload?.success) {
+        toast.success(t('Model deleted'))
+        await fetchOllamaModels()
+        queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+        setDeleteOpen(false)
+        setDeleteTarget(null)
+      } else {
+        toast.error(payload?.message || t('Failed to delete model'))
+      }
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : undefined
+      toast.error(msg || t('Failed to delete model'))
+    } finally {
+      setIsDeleting(false)
+    }
+  }
+
+  const close = () => {
+    pullAbortRef.current?.abort()
+    pullAbortRef.current = null
+    onOpenChange(false)
+  }
+
+  if (!open) return null
+
+  return (
+    <Dialog open={open} onOpenChange={close}>
+      <DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
+        <DialogHeader>
+          <DialogTitle>{t('Ollama Models')}</DialogTitle>
+          <DialogDescription>
+            {t('Manage local models for:')} <strong>{currentRow?.name}</strong>
+          </DialogDescription>
+        </DialogHeader>
+
+        {!isOllamaChannel ? (
+          <div className='text-muted-foreground py-8 text-center'>
+            {t('This channel is not an Ollama channel.')}
+          </div>
+        ) : (
+          <div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
+            <div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
+              <div className='flex-1 space-y-2'>
+                <Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
+                <div className='flex gap-2'>
+                  <Input
+                    id='ollama-pull'
+                    placeholder={t('e.g. llama3.1:8b')}
+                    value={pullName}
+                    onChange={(e) => setPullName(e.target.value)}
+                    disabled={!channelId || isPulling}
+                  />
+                  <Button
+                    onClick={() => void pullModel()}
+                    disabled={!channelId || isPulling}
+                  >
+                    {isPulling ? (
+                      <>
+                        <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                        {t('Pulling...')}
+                      </>
+                    ) : (
+                      <>
+                        <Download className='mr-2 h-4 w-4' />
+                        {t('Pull')}
+                      </>
+                    )}
+                  </Button>
+                </div>
+                {pullProgress && (
+                  <div className='space-y-2'>
+                    <div className='text-muted-foreground text-xs'>
+                      {t('Status:')} {String(pullProgress.status || '-')}
+                    </div>
+                    <Progress
+                      value={
+                        typeof pullProgress.completed === 'number' &&
+                        typeof pullProgress.total === 'number' &&
+                        pullProgress.total > 0
+                          ? Math.min(
+                              100,
+                              Math.round(
+                                (pullProgress.completed / pullProgress.total) *
+                                  100
+                              )
+                            )
+                          : 0
+                      }
+                    />
+                  </div>
+                )}
+              </div>
+
+              <div className='flex gap-2'>
+                <Button
+                  variant='outline'
+                  onClick={() => void fetchOllamaModels()}
+                  disabled={!channelId || isFetching}
+                >
+                  {isFetching ? (
+                    <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                  ) : (
+                    <RefreshCw className='mr-2 h-4 w-4' />
+                  )}
+                  {t('Refresh')}
+                </Button>
+              </div>
+            </div>
+
+            <Separator />
+
+            <div className='space-y-3'>
+              <div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
+                <div>
+                  <p className='text-sm font-medium'>{t('Local models')}</p>
+                  <p className='text-muted-foreground text-xs'>
+                    {t('Select models and apply to channel models list.')}
+                  </p>
+                </div>
+                <div className='relative sm:w-72'>
+                  <Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
+                  <Input
+                    placeholder={t('Search models...')}
+                    value={search}
+                    onChange={(e) => setSearch(e.target.value)}
+                    className='pl-9'
+                  />
+                </div>
+              </div>
+
+              <div className='flex flex-wrap gap-2'>
+                <Button variant='outline' size='sm' onClick={selectAllFiltered}>
+                  {t('Select all (filtered)')}
+                </Button>
+                <Button variant='outline' size='sm' onClick={clearSelection}>
+                  {t('Clear selection')}
+                </Button>
+                <Button
+                  size='sm'
+                  onClick={() => void applySelection('append')}
+                  disabled={!selected.length}
+                >
+                  {t('Append to channel')}
+                </Button>
+                <Button
+                  variant='secondary'
+                  size='sm'
+                  onClick={() => void applySelection('replace')}
+                  disabled={!selected.length}
+                >
+                  {t('Replace channel models')}
+                </Button>
+              </div>
+
+              <div className='overflow-hidden rounded-md border'>
+                <div className='max-h-[420px] overflow-y-auto'>
+                  {filteredModels.length === 0 ? (
+                    <div className='text-muted-foreground p-6 text-center text-sm'>
+                      {t('No models found.')}
+                    </div>
+                  ) : (
+                    <div className='divide-y'>
+                      {filteredModels.map((m) => {
+                        const checked = selected.includes(m.id)
+                        return (
+                          <div
+                            key={m.id}
+                            className='flex items-center justify-between gap-3 p-3'
+                          >
+                            <div className='flex min-w-0 items-start gap-3'>
+                              <Checkbox
+                                checked={checked}
+                                onCheckedChange={(v) =>
+                                  toggleSelected(m.id, !!v)
+                                }
+                                aria-label={`Select model ${m.id}`}
+                              />
+                              <div className='min-w-0'>
+                                <div className='truncate font-mono text-sm'>
+                                  {m.id}
+                                </div>
+                                <div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
+                                  <span>
+                                    {t('Size:')} {formatBytes(m.size)}
+                                  </span>
+                                  {m.digest && (
+                                    <span className='truncate'>
+                                      {t('Digest:')} {String(m.digest)}
+                                    </span>
+                                  )}
+                                </div>
+                              </div>
+                            </div>
+
+                            <Button
+                              variant='ghost'
+                              size='sm'
+                              className='text-destructive hover:text-destructive'
+                              onClick={() => {
+                                setDeleteTarget(m.id)
+                                setDeleteOpen(true)
+                              }}
+                              disabled={!channelId}
+                            >
+                              <Trash2 className='h-4 w-4' />
+                            </Button>
+                          </div>
+                        )
+                      })}
+                    </div>
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+
+        <DialogFooter>
+          <Button variant='outline' onClick={close}>
+            {t('Close')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+
+      <AlertDialog
+        open={deleteOpen}
+        onOpenChange={(v) => {
+          setDeleteOpen(v)
+          if (!v) setDeleteTarget(null)
+        }}
+      >
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
+            <AlertDialogDescription>
+              {t('Delete model "{{name}}"? This cannot be undone.', {
+                name: deleteTarget || '',
+              })}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={isDeleting}>
+              {t('Cancel')}
+            </AlertDialogCancel>
+            <AlertDialogAction
+              className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
+              disabled={isDeleting || !deleteTarget}
+              onClick={() => {
+                if (!deleteTarget) return
+                void deleteModel(deleteTarget)
+              }}
+            >
+              {isDeleting ? (
+                <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+              ) : null}
+              {t('Delete')}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </Dialog>
+  )
+}

+ 260 - 17
web/src/features/channels/components/drawers/channel-mutate-drawer.tsx

@@ -121,6 +121,31 @@ type ModelMappingGuardrail = {
   exposedTargetModels: string[]
 }
 
+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
+}
+
 // Helper functions
 const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({
   invalidJson: false,
@@ -232,6 +257,7 @@ export function ChannelMutateDrawer({
   const currentBaseUrl = form.watch('base_url')
   const currentModels = form.watch('models')
   const currentModelMapping = form.watch('model_mapping')
+  const awsKeyType = form.watch('aws_key_type')
 
   // Helper computed values
   const isBatchMode =
@@ -548,8 +574,8 @@ export function ChannelMutateDrawer({
       } else {
         toast.error(t('No models fetched from upstream'))
       }
-    } catch (error: any) {
-      toast.error(error?.response?.data?.message || t('Failed to fetch models'))
+    } catch (error: unknown) {
+      toast.error(getErrorMessage(error) || t('Failed to fetch models'))
     } finally {
       setIsFetchingModels(false)
     }
@@ -730,14 +756,19 @@ export function ChannelMutateDrawer({
       try {
         if (isEditing && currentRow) {
           // Update existing channel
-          let payload = transformFormDataToUpdatePayload(data, currentRow.id)
-
-          // Add key_mode for multi-key channels
-          if (isMultiKeyChannel && data.key_mode) {
-            payload = { ...payload, key_mode: data.key_mode } as any
-          }
-
-          const response = await updateChannel(currentRow.id, payload)
+          const payload = transformFormDataToUpdatePayload(data, currentRow.id)
+          const payloadWithKeyMode =
+            isMultiKeyChannel && data.key_mode
+              ? {
+                  ...payload,
+                  key_mode: data.key_mode,
+                }
+              : payload
+
+          const response = await updateChannel(
+            currentRow.id,
+            payloadWithKeyMode
+          )
           if (response.success) {
             toast.success(SUCCESS_MESSAGES.UPDATED)
             handleSuccess()
@@ -751,10 +782,8 @@ export function ChannelMutateDrawer({
             handleSuccess()
           }
         }
-      } catch (error: any) {
-        toast.error(
-          error?.response?.data?.message || ERROR_MESSAGES.CREATE_FAILED
-        )
+      } catch (error: unknown) {
+        toast.error(getErrorMessage(error) || ERROR_MESSAGES.CREATE_FAILED)
       } finally {
         setIsSubmitting(false)
       }
@@ -1077,6 +1106,140 @@ export function ChannelMutateDrawer({
                   />
                 )}
 
+                {/* AWS (type 33) */}
+                {currentType === 33 && (
+                  <FormField
+                    control={form.control}
+                    name='aws_key_type'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>{t('AWS Key Format')}</FormLabel>
+                        <Select
+                          onValueChange={field.onChange}
+                          value={field.value}
+                        >
+                          <FormControl>
+                            <SelectTrigger>
+                              <SelectValue
+                                placeholder={t('Select key format')}
+                              />
+                            </SelectTrigger>
+                          </FormControl>
+                          <SelectContent>
+                            <SelectItem value='ak_sk'>
+                              {t('AccessKey / SecretAccessKey')}
+                            </SelectItem>
+                            <SelectItem value='api_key'>
+                              {t('API Key')}
+                            </SelectItem>
+                          </SelectContent>
+                        </Select>
+                        <FormDescription>
+                          {field.value === 'api_key'
+                            ? t('API Key mode: use APIKey|Region')
+                            : t(
+                                'AK/SK mode: use AccessKey|SecretAccessKey|Region'
+                              )}
+                        </FormDescription>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                )}
+
+                {/* Field passthrough controls (OpenAI type 1 / Anthropic type 14) */}
+                {(currentType === 1 || currentType === 14) && (
+                  <div className='space-y-3 rounded-lg border p-4'>
+                    <div className='space-y-0.5'>
+                      <p className='text-sm font-medium'>
+                        {t('Field passthrough controls')}
+                      </p>
+                      <p className='text-muted-foreground text-xs'>
+                        {t(
+                          'These toggles affect whether certain request fields are passed through to the upstream provider.'
+                        )}
+                      </p>
+                    </div>
+
+                    <FormField
+                      control={form.control}
+                      name='allow_service_tier'
+                      render={({ field }) => (
+                        <FormItem className='flex items-center justify-between gap-3 rounded-md border p-3'>
+                          <div className='space-y-0.5'>
+                            <FormLabel className='text-sm'>
+                              {t('Allow service_tier passthrough')}
+                            </FormLabel>
+                            <FormDescription>
+                              {t('Pass through the service_tier field')}
+                            </FormDescription>
+                          </div>
+                          <FormControl>
+                            <Switch
+                              checked={field.value}
+                              onCheckedChange={field.onChange}
+                            />
+                          </FormControl>
+                        </FormItem>
+                      )}
+                    />
+
+                    {currentType === 1 && (
+                      <>
+                        <FormField
+                          control={form.control}
+                          name='disable_store'
+                          render={({ field }) => (
+                            <FormItem className='flex items-center justify-between gap-3 rounded-md border p-3'>
+                              <div className='space-y-0.5'>
+                                <FormLabel className='text-sm'>
+                                  {t('Disable store passthrough')}
+                                </FormLabel>
+                                <FormDescription>
+                                  {t(
+                                    'When enabled, the store field will be blocked'
+                                  )}
+                                </FormDescription>
+                              </div>
+                              <FormControl>
+                                <Switch
+                                  checked={field.value}
+                                  onCheckedChange={field.onChange}
+                                />
+                              </FormControl>
+                            </FormItem>
+                          )}
+                        />
+
+                        <FormField
+                          control={form.control}
+                          name='allow_safety_identifier'
+                          render={({ field }) => (
+                            <FormItem className='flex items-center justify-between gap-3 rounded-md border p-3'>
+                              <div className='space-y-0.5'>
+                                <FormLabel className='text-sm'>
+                                  {t('Allow safety_identifier passthrough')}
+                                </FormLabel>
+                                <FormDescription>
+                                  {t(
+                                    'Pass through the safety_identifier field'
+                                  )}
+                                </FormDescription>
+                              </div>
+                              <FormControl>
+                                <Switch
+                                  checked={field.value}
+                                  onCheckedChange={field.onChange}
+                                />
+                              </FormControl>
+                            </FormItem>
+                          )}
+                        />
+                      </>
+                    )}
+                  </div>
+                )}
+
                 {/* AI Proxy Library (type 21) */}
                 {currentType === 21 && (
                   <FormField
@@ -1231,6 +1394,72 @@ export function ChannelMutateDrawer({
                         </FormItem>
                       )}
                     />
+                    {form.watch('vertex_key_type') === 'json' && (
+                      <FormItem>
+                        <FormLabel>
+                          {t('Service account JSON file(s)')}
+                        </FormLabel>
+                        <FormControl>
+                          <Input
+                            type='file'
+                            accept='.json,application/json'
+                            multiple={isBatchMode}
+                            onChange={async (e) => {
+                              const fileList = e.target.files
+                              const files = fileList ? Array.from(fileList) : []
+                              // allow re-selecting the same file
+                              e.target.value = ''
+
+                              if (files.length === 0) {
+                                toast.info(t('Please upload key file(s)'))
+                                return
+                              }
+
+                              const keys: unknown[] = []
+                              for (const file of files) {
+                                try {
+                                  const txt = await file.text()
+                                  keys.push(JSON.parse(txt))
+                                } catch {
+                                  toast.error(
+                                    t('Failed to parse JSON file: {{name}}', {
+                                      name: file.name,
+                                    })
+                                  )
+                                  return
+                                }
+                              }
+
+                              if (keys.length === 0) {
+                                toast.info(t('Please upload key file(s)'))
+                                return
+                              }
+
+                              const keyValue = isBatchMode
+                                ? JSON.stringify(keys)
+                                : JSON.stringify(keys[0])
+
+                              form.setValue('key', keyValue, {
+                                shouldDirty: true,
+                                shouldValidate: true,
+                              })
+
+                              toast.success(
+                                t('Parsed {{count}} service account file(s)', {
+                                  count: keys.length,
+                                })
+                              )
+                            }}
+                          />
+                        </FormControl>
+                        <FormDescription>
+                          {isBatchMode
+                            ? t('Upload multiple JSON files in batch modes')
+                            : t('Upload a single service account JSON file')}
+                        </FormDescription>
+                        <FormMessage />
+                      </FormItem>
+                    )}
                     <FormField
                       control={form.control}
                       name='other'
@@ -1444,9 +1673,23 @@ export function ChannelMutateDrawer({
                           placeholder={
                             isEditing
                               ? 'Leave empty to keep existing key'
-                              : isBatchMode
-                                ? 'Enter one key per line for batch creation'
-                                : getKeyPromptForType(currentType)
+                              : currentType === 33
+                                ? awsKeyType === 'api_key'
+                                  ? isBatchMode
+                                    ? t(
+                                        'Enter API Key, one per line, format: APIKey|Region'
+                                      )
+                                    : t('Enter API Key, format: APIKey|Region')
+                                  : isBatchMode
+                                    ? t(
+                                        'Enter key, one per line, format: AccessKey|SecretAccessKey|Region'
+                                      )
+                                    : t(
+                                        'Enter key, format: AccessKey|SecretAccessKey|Region'
+                                      )
+                                : isBatchMode
+                                  ? 'Enter one key per line for batch creation'
+                                  : getKeyPromptForType(currentType)
                           }
                           rows={isBatchMode ? 8 : 4}
                           {...field}

+ 54 - 0
web/src/features/channels/lib/channel-form.ts

@@ -46,7 +46,12 @@ export const channelFormSchema = z.object({
   // Type-specific settings (stored in settings JSON)
   is_enterprise_account: z.boolean().optional(), // OpenRouter specific
   vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific
+  aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific
   azure_responses_version: z.string().optional(), // Azure specific
+  // Field passthrough controls (stored in settings JSON)
+  allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic
+  disable_store: z.boolean().optional(), // OpenAI only
+  allow_safety_identifier: z.boolean().optional(), // OpenAI only
 })
 
 export type ChannelFormValues = z.infer<typeof channelFormSchema>
@@ -91,7 +96,12 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
   // Type-specific settings
   is_enterprise_account: false,
   vertex_key_type: 'json',
+  aws_key_type: 'ak_sk',
   azure_responses_version: '',
+  // Field passthrough controls
+  allow_service_tier: false,
+  disable_store: false,
+  allow_safety_identifier: false,
 }
 
 // ============================================================================
@@ -134,6 +144,10 @@ export function transformChannelToFormDefaults(
   let vertexKeyType: 'json' | 'api_key' = 'json'
   let azureResponsesVersion = ''
   let isEnterpriseAccount = false
+  let awsKeyType: 'ak_sk' | 'api_key' = 'ak_sk'
+  let allowServiceTier = false
+  let disableStore = false
+  let allowSafetyIdentifier = false
 
   if (channel.settings) {
     try {
@@ -141,6 +155,10 @@ export function transformChannelToFormDefaults(
       vertexKeyType = parsed.vertex_key_type || 'json'
       azureResponsesVersion = parsed.azure_responses_version || ''
       isEnterpriseAccount = parsed.openrouter_enterprise === true
+      awsKeyType = parsed.aws_key_type || 'ak_sk'
+      allowServiceTier = parsed.allow_service_tier === true
+      disableStore = parsed.disable_store === true
+      allowSafetyIdentifier = parsed.allow_safety_identifier === true
     } catch (error) {
       console.error('Failed to parse channel settings:', error)
     }
@@ -178,6 +196,10 @@ export function transformChannelToFormDefaults(
     is_enterprise_account: isEnterpriseAccount,
     vertex_key_type: vertexKeyType,
     azure_responses_version: azureResponsesVersion,
+    aws_key_type: awsKeyType,
+    allow_service_tier: allowServiceTier,
+    disable_store: disableStore,
+    allow_safety_identifier: allowSafetyIdentifier,
   }
 }
 
@@ -214,16 +236,48 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
   // Add vertex_key_type for Vertex AI channels (type 41)
   if (formData.type === 41) {
     settingsObj.vertex_key_type = formData.vertex_key_type || 'json'
+  } else if ('vertex_key_type' in settingsObj) {
+    delete settingsObj.vertex_key_type
   }
 
   // Add azure_responses_version for Azure channels (type 3)
   if (formData.type === 3 && formData.azure_responses_version) {
     settingsObj.azure_responses_version = formData.azure_responses_version
+  } else if ('azure_responses_version' in settingsObj) {
+    delete settingsObj.azure_responses_version
   }
 
   // Add enterprise account setting for OpenRouter (type 20)
   if (formData.type === 20) {
     settingsObj.openrouter_enterprise = formData.is_enterprise_account === true
+  } else if ('openrouter_enterprise' in settingsObj) {
+    delete settingsObj.openrouter_enterprise
+  }
+
+  // Add aws_key_type for AWS channels (type 33)
+  if (formData.type === 33) {
+    settingsObj.aws_key_type = formData.aws_key_type || 'ak_sk'
+  } else if ('aws_key_type' in settingsObj) {
+    delete settingsObj.aws_key_type
+  }
+
+  // Field passthrough controls:
+  // - OpenAI (type 1) and Anthropic (type 14): allow_service_tier
+  // - OpenAI only: disable_store, allow_safety_identifier
+  if (formData.type === 1 || formData.type === 14) {
+    settingsObj.allow_service_tier = formData.allow_service_tier === true
+  } else if ('allow_service_tier' in settingsObj) {
+    delete settingsObj.allow_service_tier
+  }
+
+  if (formData.type === 1) {
+    settingsObj.disable_store = formData.disable_store === true
+    settingsObj.allow_safety_identifier =
+      formData.allow_safety_identifier === true
+  } else {
+    if ('disable_store' in settingsObj) delete settingsObj.disable_store
+    if ('allow_safety_identifier' in settingsObj)
+      delete settingsObj.allow_safety_identifier
   }
 
   return JSON.stringify(settingsObj)

+ 143 - 0
web/src/features/channels/lib/ollama-utils.ts

@@ -0,0 +1,143 @@
+import type { Channel } from '../types'
+
+export type PullProgress = {
+  status?: string
+  completed?: number
+  total?: number
+  // backend may include extra fields
+  [k: string]: unknown
+}
+
+export type OllamaModel = {
+  id: string
+  owned_by?: string
+  size?: number
+  digest?: string
+  modified_at?: string
+  details?: unknown
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null
+}
+
+function getString(value: unknown): string | undefined {
+  return typeof value === 'string' ? value : undefined
+}
+
+function getNumber(value: unknown): number | undefined {
+  return typeof value === 'number' ? value : undefined
+}
+
+function parseMaybeJSON(value: unknown) {
+  if (!value) return null
+  if (typeof value === 'object') return value
+  if (typeof value === 'string') {
+    try {
+      return JSON.parse(value)
+    } catch {
+      return null
+    }
+  }
+  return null
+}
+
+/**
+ * Resolve Ollama base URL from channel fields (supports legacy/alternate fields).
+ */
+export function resolveOllamaBaseUrl(channel: Channel | null) {
+  if (!channel) return ''
+
+  const direct =
+    typeof channel.base_url === 'string' ? channel.base_url.trim() : ''
+  if (direct) return direct
+
+  const alt =
+    typeof (channel as unknown as { ollama_base_url?: unknown })
+      ?.ollama_base_url === 'string'
+      ? String(
+          (channel as unknown as { ollama_base_url?: string }).ollama_base_url
+        ).trim()
+      : ''
+  if (alt) return alt
+
+  const parsed = parseMaybeJSON(channel.other_info)
+  if (isRecord(parsed)) {
+    const baseUrl = getString(parsed.base_url)?.trim()
+    if (baseUrl) return baseUrl
+    const publicUrl = getString(parsed.public_url)?.trim()
+    if (publicUrl) return publicUrl
+    const apiUrl = getString(parsed.api_url)?.trim()
+    if (apiUrl) return apiUrl
+  }
+
+  return ''
+}
+
+export function normalizeOllamaModels(items: unknown): OllamaModel[] {
+  if (!Array.isArray(items)) return []
+
+  return items
+    .map((item) => {
+      if (!item) return null
+
+      if (typeof item === 'string') {
+        return { id: item, owned_by: 'ollama' } satisfies OllamaModel
+      }
+
+      if (isRecord(item)) {
+        const candidateId =
+          getString(item.id) ||
+          getString(item.ID) ||
+          getString(item.name) ||
+          getString(item.model) ||
+          getString(item.Model)
+        if (!candidateId) return null
+
+        const metadata = item.metadata ?? item.Metadata
+        const normalized: OllamaModel = {
+          ...item,
+          id: candidateId,
+          owned_by:
+            getString(item.owned_by) || getString(item.ownedBy) || 'ollama',
+        }
+
+        const itemSize = getNumber(item.size)
+        if (typeof itemSize === 'number' && !normalized.size) {
+          normalized.size = itemSize
+        }
+        if (isRecord(metadata)) {
+          const metaSize = getNumber(metadata.size)
+          if (typeof metaSize === 'number' && !normalized.size) {
+            normalized.size = metaSize
+          }
+          const metaDigest = getString(metadata.digest)
+          if (!normalized.digest && metaDigest) {
+            normalized.digest = metaDigest
+          }
+          const metaModifiedAt = getString(metadata.modified_at)
+          if (!normalized.modified_at && metaModifiedAt) {
+            normalized.modified_at = metaModifiedAt
+          }
+          if (metadata.details && !normalized.details) {
+            normalized.details = metadata.details
+          }
+        }
+        return normalized
+      }
+
+      return null
+    })
+    .filter(Boolean) as OllamaModel[]
+}
+
+export function formatBytes(bytes?: number) {
+  if (typeof bytes !== 'number' || Number.isNaN(bytes)) return '-'
+  if (bytes < 1024) return `${bytes} B`
+  const kb = bytes / 1024
+  if (kb < 1024) return `${kb.toFixed(1)} KB`
+  const mb = kb / 1024
+  if (mb < 1024) return `${mb.toFixed(1)} MB`
+  const gb = mb / 1024
+  return `${gb.toFixed(2)} GB`
+}

+ 4 - 0
web/src/features/channels/types.ts

@@ -74,6 +74,10 @@ export interface ChannelOtherSettings {
   azure_responses_version?: string
   vertex_key_type?: 'json' | 'api_key'
   openrouter_enterprise?: boolean
+  aws_key_type?: 'ak_sk' | 'api_key'
+  allow_service_tier?: boolean
+  disable_store?: boolean
+  allow_safety_identifier?: boolean
 }
 
 // ============================================================================

+ 346 - 0
web/src/features/models/api.ts

@@ -15,6 +15,8 @@ import type {
   SyncLocale,
   SyncSource,
   SyncOverwritePayload,
+  DeploymentSettingsResponse,
+  ListDeploymentsResponse,
 } from './types'
 
 // ============================================================================
@@ -266,3 +268,347 @@ export async function deletePrefillGroup(
   const res = await api.delete(`/api/prefill_group/${id}`)
   return res.data
 }
+
+// ============================================================================
+// Deployment Operations
+// ============================================================================
+
+/**
+ * Get deployment settings (io.net config)
+ */
+export async function getDeploymentSettings(): Promise<DeploymentSettingsResponse> {
+  const res = await api.get('/api/deployments/settings')
+  return res.data
+}
+
+/**
+ * Test deployment connection
+ */
+export async function testDeploymentConnection(): Promise<{
+  success: boolean
+  message?: string
+}> {
+  const config = { skipErrorHandler: true } as unknown as Parameters<
+    typeof api.post
+  >[2]
+  const res = await api.post(
+    '/api/deployments/settings/test-connection',
+    {},
+    config
+  )
+  return res.data
+}
+
+/**
+ * Test deployment connection with optional api_key (allow test before saving)
+ */
+export async function testDeploymentConnectionWithKey(
+  apiKey?: string
+): Promise<{
+  success: boolean
+  message?: string
+}> {
+  const payload =
+    typeof apiKey === 'string' && apiKey.trim()
+      ? { api_key: apiKey.trim() }
+      : {}
+  const config = { skipErrorHandler: true } as unknown as Parameters<
+    typeof api.post
+  >[2]
+  const res = await api.post(
+    '/api/deployments/settings/test-connection',
+    payload,
+    config
+  )
+  return res.data
+}
+
+/**
+ * List deployments
+ */
+export async function listDeployments(params: {
+  p?: number
+  page_size?: number
+  status?: string
+}): Promise<ListDeploymentsResponse> {
+  const res = await api.get('/api/deployments/', { params })
+  return res.data
+}
+
+/**
+ * Search deployments (keyword + status)
+ *
+ * Backend exposes a dedicated `/api/deployments/search` route that supports
+ * filtering by keyword (and status). Use this when keyword is provided.
+ */
+export async function searchDeployments(params: {
+  p?: number
+  page_size?: number
+  status?: string
+  keyword?: string
+}): Promise<ListDeploymentsResponse> {
+  const res = await api.get('/api/deployments/search', { params })
+  return res.data
+}
+
+/**
+ * Get deployment details
+ */
+export async function getDeployment(id: string | number): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const res = await api.get(`/api/deployments/${id}`)
+  return res.data
+}
+
+/**
+ * List deployment containers
+ */
+export async function listDeploymentContainers(
+  deploymentId: string | number
+): Promise<{
+  success: boolean
+  message?: string
+  data?: {
+    total?: number
+    containers?: Array<Record<string, unknown>>
+  }
+}> {
+  const res = await api.get(`/api/deployments/${deploymentId}/containers`)
+  return res.data
+}
+
+/**
+ * Get single container details
+ */
+export async function getDeploymentContainerDetails(
+  deploymentId: string | number,
+  containerId: string
+): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const res = await api.get(
+    `/api/deployments/${deploymentId}/containers/${encodeURIComponent(containerId)}`
+  )
+  return res.data
+}
+
+/**
+ * Delete deployment
+ */
+export async function deleteDeployment(
+  id: string | number
+): Promise<{ success: boolean; message?: string }> {
+  const res = await api.delete(`/api/deployments/${id}`)
+  return res.data
+}
+
+/**
+ * Get deployment logs (raw)
+ */
+export async function getDeploymentLogs(
+  deploymentId: string | number,
+  params: {
+    container_id: string
+    stream?: 'stdout' | 'stderr' | 'all' | string
+    level?: string
+    cursor?: string
+    limit?: number
+    follow?: boolean
+    start_time?: string
+    end_time?: string
+  }
+): Promise<{ success: boolean; message?: string; data?: string }> {
+  const res = await api.get(`/api/deployments/${deploymentId}/logs`, { params })
+  return res.data
+}
+
+/**
+ * Get hardware types for deployment
+ */
+export async function getHardwareTypes(): Promise<{
+  success: boolean
+  data?: { hardware_types?: Array<Record<string, unknown>> }
+}> {
+  const res = await api.get('/api/deployments/hardware-types')
+  return res.data
+}
+
+/**
+ * Get locations for deployment
+ */
+export async function getDeploymentLocations(): Promise<{
+  success: boolean
+  message?: string
+  data?: { locations?: Array<Record<string, unknown>>; total?: number }
+}> {
+  const res = await api.get('/api/deployments/locations')
+  return res.data
+}
+
+/**
+ * Get available replicas
+ */
+export async function getAvailableReplicas(params: {
+  hardware_id: string
+  gpu_count: number
+}): Promise<{
+  success: boolean
+  data?: { replicas?: Array<Record<string, unknown>> }
+}> {
+  const res = await api.get('/api/deployments/available-replicas', { params })
+  return res.data
+}
+
+/**
+ * Estimate deployment price
+ */
+export async function estimatePrice(params: {
+  location_ids: Array<string | number>
+  hardware_id: string | number
+  gpus_per_container: number
+  duration_hours: number
+  replica_count: number
+  currency?: string
+}): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const locationIds = (params.location_ids || [])
+    .map((x) => Number(x))
+    .filter((n) => Number.isInteger(n) && n > 0)
+  const hardwareId = Number(params.hardware_id)
+  const duration = Number(params.duration_hours)
+  const gpus = Number(params.gpus_per_container)
+  const replicaCount = Number(params.replica_count)
+  const currency =
+    typeof params.currency === 'string' && params.currency.trim()
+      ? params.currency.trim().toLowerCase()
+      : 'usdc'
+
+  const payload = {
+    location_ids: locationIds,
+    hardware_id: hardwareId,
+    gpus_per_container: gpus,
+    duration_hours: duration,
+    replica_count: replicaCount,
+    currency,
+    duration_type: 'hour',
+    duration_qty: duration,
+    hardware_qty: gpus,
+  }
+
+  const res = await api.post('/api/deployments/price-estimation', payload)
+  return res.data
+}
+
+/**
+ * Create deployment
+ */
+export async function createDeployment(data: {
+  resource_private_name: string
+  duration_hours: number
+  gpus_per_container: number
+  hardware_id: number
+  location_ids: number[]
+  container_config: {
+    replica_count: number
+    env_variables?: Record<string, string>
+    secret_env_variables?: Record<string, string>
+    entrypoint?: string[]
+    traffic_port?: number
+    args?: string[]
+  }
+  registry_config: {
+    image_url: string
+    registry_username?: string
+    registry_secret?: string
+  }
+}): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const res = await api.post('/api/deployments/', data)
+  return res.data
+}
+
+/**
+ * Update deployment configuration
+ */
+export async function updateDeployment(
+  id: string | number,
+  data: {
+    env_variables?: Record<string, string>
+    secret_env_variables?: Record<string, string>
+    entrypoint?: string[]
+    traffic_port?: number | null
+    image_url?: string
+    registry_username?: string
+    registry_secret?: string
+    args?: string[]
+    command?: string
+  }
+): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const payload: Record<string, unknown> = { ...data }
+  if (data.traffic_port === null) {
+    delete payload.traffic_port
+  }
+  const res = await api.put(`/api/deployments/${id}`, payload)
+  return res.data
+}
+
+/**
+ * Update deployment name
+ */
+export async function updateDeploymentName(
+  id: string | number,
+  name: string
+): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const res = await api.put(`/api/deployments/${id}/name`, { name })
+  return res.data
+}
+
+/**
+ * Extend deployment duration
+ */
+export async function extendDeployment(
+  id: string | number,
+  durationHours: number
+): Promise<{
+  success: boolean
+  message?: string
+  data?: Record<string, unknown>
+}> {
+  const res = await api.post(`/api/deployments/${id}/extend`, {
+    duration_hours: durationHours,
+  })
+  return res.data
+}
+
+/**
+ * Check cluster name availability
+ */
+export async function checkClusterNameAvailability(name: string): Promise<{
+  success: boolean
+  message?: string
+  data?: { available?: boolean; name?: string }
+}> {
+  const res = await api.get('/api/deployments/check-name', {
+    params: { name },
+  })
+  return res.data
+}

+ 122 - 0
web/src/features/models/components/deployment-access-guard.tsx

@@ -0,0 +1,122 @@
+import type { ReactNode } from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { AlertCircle, Loader2, Server, Settings, WifiOff } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+
+interface DeploymentAccessGuardProps {
+  children: ReactNode
+  loading: boolean
+  isEnabled: boolean
+  connectionLoading: boolean
+  connectionOk: boolean | null
+  connectionError: string | null
+  onRetry: () => void
+}
+
+export function DeploymentAccessGuard({
+  children,
+  loading,
+  isEnabled,
+  connectionLoading,
+  connectionOk,
+  connectionError,
+  onRetry,
+}: DeploymentAccessGuardProps) {
+  const { t } = useTranslation()
+  const navigate = useNavigate()
+
+  const handleGoToSettings = () => {
+    navigate({ to: '/system-settings/integrations' })
+  }
+
+  // Loading state
+  if (loading) {
+    return (
+      <div className='mx-auto mt-8 max-w-md'>
+        <div className='flex flex-col items-center justify-center py-12'>
+          <Loader2 className='text-muted-foreground mb-4 h-12 w-12 animate-spin' />
+          <p className='text-muted-foreground'>{t('Loading...')}</p>
+        </div>
+      </div>
+    )
+  }
+
+  // Disabled state
+  if (!isEnabled) {
+    return (
+      <div className='mx-auto mt-8 max-w-md'>
+        <div className='text-center'>
+          <div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/20'>
+            <Server className='h-8 w-8 text-amber-600 dark:text-amber-400' />
+          </div>
+          <h3 className='mb-6 text-xl font-semibold'>
+            {t('Model deployment service is disabled')}
+          </h3>
+        </div>
+        <div className='space-y-4'>
+          <Alert variant='default'>
+            <AlertCircle className='h-4 w-4' />
+            <AlertTitle>{t('Configuration required')}</AlertTitle>
+            <AlertDescription>
+              {t(
+                'Please enable io.net model deployment service and configure an API key in System Settings.'
+              )}
+            </AlertDescription>
+          </Alert>
+          <Button onClick={handleGoToSettings} className='w-full'>
+            <Settings className='mr-2 h-4 w-4' />
+            {t('Go to settings')}
+          </Button>
+        </div>
+      </div>
+    )
+  }
+
+  // Connection loading state
+  if (connectionLoading) {
+    return (
+      <div className='mx-auto mt-8 max-w-md'>
+        <div className='flex flex-col items-center justify-center py-12'>
+          <Loader2 className='text-muted-foreground mb-4 h-12 w-12 animate-spin' />
+          <p className='text-muted-foreground'>{t('Checking connection...')}</p>
+        </div>
+      </div>
+    )
+  }
+
+  // Connection error state
+  if (connectionOk === false && connectionError) {
+    return (
+      <div className='mx-auto mt-8 max-w-md'>
+        <div className='text-center'>
+          <div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20'>
+            <WifiOff className='h-8 w-8 text-red-600 dark:text-red-400' />
+          </div>
+          <h3 className='mb-6 text-xl font-semibold'>
+            {t('Connection failed')}
+          </h3>
+        </div>
+        <div className='space-y-4'>
+          <Alert variant='destructive'>
+            <AlertCircle className='h-4 w-4' />
+            <AlertTitle>{t('Connection error')}</AlertTitle>
+            <AlertDescription>{connectionError}</AlertDescription>
+          </Alert>
+          <div className='flex gap-2'>
+            <Button variant='outline' onClick={onRetry} className='flex-1'>
+              {t('Retry')}
+            </Button>
+            <Button onClick={handleGoToSettings} className='flex-1'>
+              <Settings className='mr-2 h-4 w-4' />
+              {t('Go to settings')}
+            </Button>
+          </div>
+        </div>
+      </div>
+    )
+  }
+
+  return <>{children}</>
+}

+ 303 - 0
web/src/features/models/components/deployments-columns.tsx

@@ -0,0 +1,303 @@
+import { type ColumnDef } from '@tanstack/react-table'
+import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { formatTimestampToDate } from '@/lib/format'
+import { Button } from '@/components/ui/button'
+import { DataTableColumnHeader } from '@/components/data-table/column-header'
+import { StatusBadge } from '@/components/status-badge'
+import { getDeploymentStatusConfig } from '../constants'
+import {
+  formatRemainingMinutes,
+  normalizeDeploymentStatus,
+} from '../lib/deployments-utils'
+import type { Deployment } from '../types'
+
+export function useDeploymentsColumns(opts: {
+  onViewLogs: (id: string | number) => void
+  onViewDetails: (id: string | number) => void
+  onUpdateConfig: (id: string | number) => void
+  onExtend: (id: string | number) => void
+  onRename: (id: string | number, currentName: string) => void
+  onDelete: (deployment: Deployment) => void
+}): ColumnDef<Deployment>[] {
+  const { t } = useTranslation()
+  const STATUS = getDeploymentStatusConfig(t)
+
+  return [
+    {
+      accessorKey: 'id',
+      meta: { label: t('ID') },
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('ID')} />
+      ),
+      cell: ({ row }) => {
+        const id = row.original.id
+        return (
+          <StatusBadge
+            label={String(id)}
+            variant='neutral'
+            copyText={String(id)}
+            size='sm'
+            className='font-mono'
+          />
+        )
+      },
+      size: 120,
+    },
+    {
+      id: 'name',
+      accessorFn: (row) =>
+        row.container_name || row.deployment_name || row.name || '',
+      meta: { label: t('Name') },
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Name')} />
+      ),
+      cell: ({ getValue }) => {
+        const name = String(getValue() || '-') || '-'
+        return (
+          <StatusBadge
+            label={name}
+            variant='neutral'
+            copyText={name}
+            size='sm'
+            className='font-mono'
+          />
+        )
+      },
+      minSize: 220,
+    },
+    {
+      accessorKey: 'status',
+      meta: { label: t('Status') },
+      header: t('Status'),
+      cell: ({ row }) => {
+        const raw = row.original.status
+        const key = normalizeDeploymentStatus(raw)
+        const config = STATUS[key] || {
+          label:
+            typeof raw === 'string' && raw.trim() ? raw.trim() : t('Unknown'),
+          variant: 'neutral' as const,
+        }
+        return (
+          <StatusBadge
+            label={config.label}
+            variant={config.variant}
+            showDot={config.showDot}
+            size='sm'
+            copyable={false}
+            rounded='full'
+          />
+        )
+      },
+      filterFn: (row, id, value) => {
+        if (
+          !Array.isArray(value) ||
+          value.length === 0 ||
+          value.includes('all')
+        ) {
+          return true
+        }
+        const status = normalizeDeploymentStatus(row.getValue(id))
+        return value.includes(status)
+      },
+      size: 160,
+      enableSorting: false,
+    },
+    {
+      accessorKey: 'provider',
+      meta: { label: t('Provider') },
+      header: t('Provider'),
+      cell: ({ row }) => {
+        const provider = row.original.provider
+        if (!provider)
+          return <span className='text-muted-foreground text-xs'>-</span>
+        return (
+          <StatusBadge
+            label={String(provider)}
+            autoColor={String(provider)}
+            size='sm'
+            copyable={false}
+            rounded='full'
+          />
+        )
+      },
+      size: 140,
+      enableSorting: false,
+    },
+    {
+      accessorKey: 'time_remaining',
+      meta: { label: t('Time remaining') },
+      header: t('Time remaining'),
+      cell: ({ row }) => {
+        const status = normalizeDeploymentStatus(row.original.status)
+        const remainingText =
+          typeof row.original.time_remaining === 'string' &&
+          row.original.time_remaining.trim()
+            ? row.original.time_remaining.trim()
+            : '-'
+        const remainingHuman = formatRemainingMinutes(
+          row.original.compute_minutes_remaining
+        )
+        const percentUsed =
+          typeof row.original.completed_percent === 'number' &&
+          Number.isFinite(row.original.completed_percent)
+            ? Math.max(
+                0,
+                Math.min(100, Math.round(row.original.completed_percent))
+              )
+            : null
+        const percentRemain =
+          percentUsed === null
+            ? null
+            : Math.max(0, Math.min(100, 100 - percentUsed))
+
+        return (
+          <div className='flex flex-col gap-1 text-sm'>
+            <div className='flex flex-wrap items-center gap-2'>
+              <span className='font-medium'>{remainingText}</span>
+              {status === 'running' && percentRemain !== null ? (
+                <StatusBadge
+                  label={`${percentRemain}%`}
+                  variant='info'
+                  size='sm'
+                  copyable={false}
+                  rounded='full'
+                />
+              ) : null}
+            </div>
+            {remainingHuman ? (
+              <div className='text-muted-foreground text-xs'>
+                {t('Approx.')} {remainingHuman}
+              </div>
+            ) : null}
+          </div>
+        )
+      },
+      minSize: 220,
+      enableSorting: false,
+    },
+    {
+      id: 'hardware',
+      meta: { label: t('Hardware') },
+      header: t('Hardware'),
+      accessorFn: (row) =>
+        row.hardware_info || row.hardware_name || row.brand_name || '',
+      cell: ({ row }) => {
+        const hardware =
+          row.original.hardware_name ||
+          (typeof row.original.hardware_info === 'string'
+            ? row.original.hardware_info
+            : '')
+        const qty =
+          typeof row.original.hardware_quantity === 'number'
+            ? row.original.hardware_quantity
+            : null
+        if (!hardware)
+          return <span className='text-muted-foreground text-xs'>-</span>
+        return (
+          <div className='flex flex-wrap items-center gap-2'>
+            <StatusBadge
+              label={String(hardware)}
+              variant='neutral'
+              copyText={String(hardware)}
+              size='sm'
+            />
+            {qty !== null ? (
+              <span className='text-muted-foreground text-xs'>×{qty}</span>
+            ) : null}
+          </div>
+        )
+      },
+      minSize: 220,
+      enableSorting: false,
+    },
+    {
+      accessorKey: 'created_at',
+      meta: { label: t('Created') },
+      header: ({ column }) => (
+        <DataTableColumnHeader column={column} title={t('Created')} />
+      ),
+      cell: ({ row }) => {
+        const ts =
+          typeof row.original.created_at === 'number'
+            ? row.original.created_at
+            : typeof row.original.created_at === 'string'
+              ? Number(row.original.created_at)
+              : undefined
+        return (
+          <div className='min-w-[140px] font-mono text-sm'>
+            {formatTimestampToDate(ts)}
+          </div>
+        )
+      },
+      size: 180,
+    },
+    {
+      id: 'actions',
+      enableHiding: false,
+      enableSorting: false,
+      cell: ({ row }) => {
+        const id = row.original.id
+        const currentName =
+          row.original.container_name ||
+          row.original.deployment_name ||
+          row.original.name ||
+          ''
+
+        return (
+          <div className='flex items-center gap-1'>
+            <Button
+              variant='ghost'
+              size='sm'
+              onClick={() => opts.onViewLogs(id)}
+              title={t('View logs')}
+            >
+              <Eye className='h-4 w-4' />
+            </Button>
+            <Button
+              variant='ghost'
+              size='sm'
+              onClick={() => opts.onViewDetails(id)}
+              title={t('View details')}
+            >
+              <Info className='h-4 w-4' />
+            </Button>
+            <Button
+              variant='ghost'
+              size='sm'
+              onClick={() => opts.onUpdateConfig(id)}
+              title={t('Update configuration')}
+            >
+              <Settings2 className='h-4 w-4' />
+            </Button>
+            <Button
+              variant='ghost'
+              size='sm'
+              onClick={() => opts.onExtend(id)}
+              title={t('Extend deployment')}
+            >
+              <Timer className='h-4 w-4' />
+            </Button>
+            <Button
+              variant='ghost'
+              size='sm'
+              onClick={() => opts.onRename(id, String(currentName))}
+              title={t('Rename deployment')}
+            >
+              <Pencil className='h-4 w-4' />
+            </Button>
+            <Button
+              variant='ghost'
+              size='sm'
+              onClick={() => opts.onDelete(row.original)}
+              title={t('Delete')}
+            >
+              <Trash2 className='h-4 w-4 text-red-500' />
+            </Button>
+          </div>
+        )
+      },
+      size: 180,
+    },
+  ]
+}

+ 401 - 0
web/src/features/models/components/deployments-table.tsx

@@ -0,0 +1,401 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { getRouteApi } from '@tanstack/react-router'
+import {
+  flexRender,
+  getCoreRowModel,
+  useReactTable,
+  type VisibilityState,
+} from '@tanstack/react-table'
+import { Plus } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { useMediaQuery } from '@/hooks/use-media-query'
+import { useTableUrlState } from '@/hooks/use-table-url-state'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import {
+  DataTableToolbar,
+  MobileCardList,
+  TableEmpty,
+  TableSkeleton,
+} from '@/components/data-table'
+import { DataTablePagination } from '@/components/data-table/pagination'
+import { deleteDeployment, listDeployments, searchDeployments } from '../api'
+import { getDeploymentStatusOptions } from '../constants'
+import { deploymentsQueryKeys } from '../lib'
+import type { Deployment } from '../types'
+import { useDeploymentsColumns } from './deployments-columns'
+import { CreateDeploymentDialog } from './dialogs/create-deployment-dialog'
+import { ExtendDeploymentDialog } from './dialogs/extend-deployment-dialog'
+import { RenameDeploymentDialog } from './dialogs/rename-deployment-dialog'
+import { UpdateConfigDialog } from './dialogs/update-config-dialog'
+import { ViewDetailsDialog } from './dialogs/view-details-dialog'
+import { ViewLogsDialog } from './dialogs/view-logs-dialog'
+
+const route = getRouteApi('/_authenticated/models/')
+
+export function DeploymentsTable() {
+  const { t } = useTranslation()
+  const queryClient = useQueryClient()
+  const isMobile = useMediaQuery('(max-width: 640px)')
+
+  // URL state (use dedicated keys so it won't collide with metadata table)
+  const {
+    globalFilter,
+    onGlobalFilterChange,
+    columnFilters,
+    onColumnFiltersChange,
+    pagination,
+    onPaginationChange,
+    ensurePageInRange,
+  } = useTableUrlState({
+    search: route.useSearch(),
+    navigate: route.useNavigate(),
+    pagination: {
+      pageKey: 'dPage',
+      pageSizeKey: 'dPageSize',
+      defaultPage: 1,
+      defaultPageSize: 10,
+    },
+    globalFilter: { enabled: true, key: 'dFilter' },
+    columnFilters: [
+      { columnId: 'status', searchKey: 'dStatus', type: 'array' },
+    ],
+  })
+
+  const keyword = globalFilter ?? ''
+  const statusFilter =
+    (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
+  const activeStatus =
+    statusFilter.length > 0 && !statusFilter.includes('all')
+      ? statusFilter[0]
+      : undefined
+
+  // Dialog state
+  const [logsOpen, setLogsOpen] = useState(false)
+  const [logsDeploymentId, setLogsDeploymentId] = useState<
+    string | number | null
+  >(null)
+  const [detailsOpen, setDetailsOpen] = useState(false)
+  const [detailsDeploymentId, setDetailsDeploymentId] = useState<
+    string | number | null
+  >(null)
+  const [updateOpen, setUpdateOpen] = useState(false)
+  const [updateDeploymentId, setUpdateDeploymentId] = useState<
+    string | number | null
+  >(null)
+  const [extendOpen, setExtendOpen] = useState(false)
+  const [extendDeploymentId, setExtendDeploymentId] = useState<
+    string | number | null
+  >(null)
+  const [renameOpen, setRenameOpen] = useState(false)
+  const [renameDeploymentId, setRenameDeploymentId] = useState<
+    string | number | null
+  >(null)
+  const [renameCurrentName, setRenameCurrentName] = useState<string>('')
+  const [createOpen, setCreateOpen] = useState(false)
+
+  // Delete confirm
+  const [deleteOpen, setDeleteOpen] = useState(false)
+  const [deleteTarget, setDeleteTarget] = useState<Deployment | null>(null)
+  const [isDeleting, setIsDeleting] = useState(false)
+
+  const { data, isLoading } = useQuery({
+    queryKey: deploymentsQueryKeys.list({
+      keyword,
+      status: activeStatus,
+      p: pagination.pageIndex + 1,
+      page_size: pagination.pageSize,
+    }),
+    queryFn: async () => {
+      if (keyword.trim()) {
+        return searchDeployments({
+          keyword,
+          status: activeStatus,
+          p: pagination.pageIndex + 1,
+          page_size: pagination.pageSize,
+        })
+      }
+      return listDeployments({
+        status: activeStatus,
+        p: pagination.pageIndex + 1,
+        page_size: pagination.pageSize,
+      })
+    },
+    placeholderData: (prev) => prev,
+  })
+
+  const deployments = data?.data?.items || []
+  const totalCount = data?.data?.total || 0
+
+  const handleDelete = async () => {
+    if (!deleteTarget) return
+    setIsDeleting(true)
+    try {
+      const res = await deleteDeployment(deleteTarget.id)
+      if (res?.success) {
+        toast.success(t('Deleted successfully'))
+        queryClient.invalidateQueries({
+          queryKey: deploymentsQueryKeys.lists(),
+        })
+      } else {
+        toast.error(res?.message || t('Delete failed'))
+      }
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t('Delete failed'))
+    } finally {
+      setIsDeleting(false)
+      setDeleteOpen(false)
+      setDeleteTarget(null)
+    }
+  }
+
+  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+
+  const columns = useDeploymentsColumns({
+    onViewLogs: (id) => {
+      setLogsDeploymentId(id)
+      setLogsOpen(true)
+    },
+    onViewDetails: (id) => {
+      setDetailsDeploymentId(id)
+      setDetailsOpen(true)
+    },
+    onUpdateConfig: (id) => {
+      setUpdateDeploymentId(id)
+      setUpdateOpen(true)
+    },
+    onExtend: (id) => {
+      setExtendDeploymentId(id)
+      setExtendOpen(true)
+    },
+    onRename: (id, currentName) => {
+      setRenameCurrentName(currentName)
+      setRenameDeploymentId(id)
+      setRenameOpen(true)
+    },
+    onDelete: (deployment) => {
+      setDeleteTarget(deployment)
+      setDeleteOpen(true)
+    },
+  })
+
+  const table = useReactTable({
+    data: deployments,
+    columns,
+    pageCount: Math.ceil(totalCount / pagination.pageSize),
+    state: {
+      columnFilters,
+      columnVisibility,
+      pagination,
+      globalFilter,
+    },
+    onColumnFiltersChange,
+    onColumnVisibilityChange: setColumnVisibility,
+    onPaginationChange,
+    onGlobalFilterChange,
+    getCoreRowModel: getCoreRowModel(),
+    manualPagination: true,
+    manualFiltering: true,
+  })
+
+  const pageCount = table.getPageCount()
+  useEffect(() => {
+    ensurePageInRange(pageCount)
+  }, [ensurePageInRange, pageCount])
+
+  const statusFilterOptions = useMemo(() => {
+    return [...getDeploymentStatusOptions(t)].map((opt) => ({
+      label: opt.label,
+      value: opt.value,
+    }))
+  }, [t])
+
+  return (
+    <div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
+      <div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
+        <DataTableToolbar
+          table={table}
+          searchPlaceholder={t('Search deployments...')}
+          filters={[
+            {
+              columnId: 'status',
+              title: t('Status'),
+              options: statusFilterOptions,
+              singleSelect: true,
+            },
+          ]}
+        />
+
+        <Button
+          onClick={() => setCreateOpen(true)}
+          size='sm'
+          className='w-full sm:w-auto'
+        >
+          <Plus className='h-4 w-4' />
+          {t('Create deployment')}
+        </Button>
+      </div>
+
+      {isMobile ? (
+        <MobileCardList
+          table={table}
+          isLoading={isLoading}
+          emptyTitle={t('No Deployments Found')}
+          emptyDescription={t(
+            'No deployments available. Create one to get started.'
+          )}
+        />
+      ) : (
+        <div className='overflow-hidden rounded-md border'>
+          <Table>
+            <TableHeader>
+              {table.getHeaderGroups().map((headerGroup) => (
+                <TableRow key={headerGroup.id}>
+                  {headerGroup.headers.map((header) => (
+                    <TableHead
+                      key={header.id}
+                      style={{ width: header.getSize() }}
+                    >
+                      {header.isPlaceholder
+                        ? null
+                        : flexRender(
+                            header.column.columnDef.header,
+                            header.getContext()
+                          )}
+                    </TableHead>
+                  ))}
+                </TableRow>
+              ))}
+            </TableHeader>
+            <TableBody>
+              {isLoading ? (
+                <TableSkeleton table={table} keyPrefix='deployment-skeleton' />
+              ) : table.getRowModel().rows.length === 0 ? (
+                <TableEmpty
+                  colSpan={table.getVisibleLeafColumns().length}
+                  title={t('No Deployments Found')}
+                  description={t(
+                    'No deployments available. Create one to get started.'
+                  )}
+                />
+              ) : (
+                table.getRowModel().rows.map((row) => (
+                  <TableRow key={row.id}>
+                    {row.getVisibleCells().map((cell) => (
+                      <TableCell key={cell.id}>
+                        {flexRender(
+                          cell.column.columnDef.cell,
+                          cell.getContext()
+                        )}
+                      </TableCell>
+                    ))}
+                  </TableRow>
+                ))
+              )}
+            </TableBody>
+          </Table>
+        </div>
+      )}
+
+      <DataTablePagination table={table as any} />
+
+      <ViewLogsDialog
+        open={logsOpen}
+        onOpenChange={(open) => {
+          setLogsOpen(open)
+          if (!open) setLogsDeploymentId(null)
+        }}
+        deploymentId={logsDeploymentId}
+      />
+
+      <ViewDetailsDialog
+        open={detailsOpen}
+        onOpenChange={(open) => {
+          setDetailsOpen(open)
+          if (!open) setDetailsDeploymentId(null)
+        }}
+        deploymentId={detailsDeploymentId}
+      />
+
+      <UpdateConfigDialog
+        open={updateOpen}
+        onOpenChange={(open) => {
+          setUpdateOpen(open)
+          if (!open) setUpdateDeploymentId(null)
+        }}
+        deploymentId={updateDeploymentId}
+      />
+
+      <ExtendDeploymentDialog
+        open={extendOpen}
+        onOpenChange={(open) => {
+          setExtendOpen(open)
+          if (!open) setExtendDeploymentId(null)
+        }}
+        deploymentId={extendDeploymentId}
+      />
+
+      <RenameDeploymentDialog
+        open={renameOpen}
+        onOpenChange={(open) => {
+          setRenameOpen(open)
+          if (!open) setRenameDeploymentId(null)
+        }}
+        deploymentId={renameDeploymentId}
+        currentName={renameCurrentName}
+      />
+
+      <CreateDeploymentDialog open={createOpen} onOpenChange={setCreateOpen} />
+
+      <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
+            <AlertDialogDescription>
+              {t(
+                'Are you sure you want to delete deployment "{{name}}"? This action cannot be undone.',
+                {
+                  name:
+                    deleteTarget?.container_name ||
+                    deleteTarget?.deployment_name ||
+                    deleteTarget?.id,
+                }
+              )}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={isDeleting}>
+              {t('Cancel')}
+            </AlertDialogCancel>
+            <AlertDialogAction
+              onClick={handleDelete}
+              disabled={isDeleting}
+              className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
+            >
+              {isDeleting ? t('Deleting...') : t('Delete')}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  )
+}

+ 723 - 0
web/src/features/models/components/dialogs/create-deployment-dialog.tsx

@@ -0,0 +1,723 @@
+import { useEffect, useMemo } from 'react'
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { MultiSelect } from '@/components/multi-select'
+import {
+  checkClusterNameAvailability,
+  createDeployment,
+  estimatePrice,
+  getAvailableReplicas,
+  getHardwareTypes,
+} from '../../api'
+import { deploymentsQueryKeys } from '../../lib'
+
+const BUILTIN_IMAGE = 'ollama/ollama:latest'
+const DEFAULT_TRAFFIC_PORT = 11434
+
+const schema = z.object({
+  resource_private_name: z.string().min(1),
+  image_url: z.string().min(1),
+  traffic_port: z.coerce.number().int().min(1).max(65535),
+  hardware_id: z.string().min(1),
+  gpus_per_container: z.coerce.number().int().min(1),
+  location_ids: z.array(z.string()).min(1),
+  replica_count: z.coerce.number().int().min(1),
+  duration_hours: z.coerce.number().int().min(1),
+  // Advanced
+  env_json: z.string().optional(),
+  secret_env_json: z.string().optional(),
+  entrypoint: z.string().optional(),
+  args: z.string().optional(),
+  registry_username: z.string().optional(),
+  registry_secret: z.string().optional(),
+  currency: z.string().optional(),
+  enable_price_estimation: z.boolean().optional(),
+})
+
+// NOTE: react-hook-form resolver uses the schema input type (coerce input is unknown)
+type FormValues = z.input<typeof schema>
+
+function toNumber(value: unknown, fallback: number) {
+  const n = typeof value === 'number' ? value : Number(value)
+  return Number.isFinite(n) ? n : fallback
+}
+
+export function CreateDeploymentDialog({
+  open,
+  onOpenChange,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}) {
+  const { t } = useTranslation()
+  const queryClient = useQueryClient()
+
+  const form = useForm<FormValues>({
+    resolver: zodResolver(schema),
+    defaultValues: {
+      resource_private_name: '',
+      image_url: BUILTIN_IMAGE,
+      traffic_port: DEFAULT_TRAFFIC_PORT,
+      hardware_id: '',
+      gpus_per_container: 1,
+      location_ids: [],
+      replica_count: 1,
+      duration_hours: 1,
+      env_json: '',
+      secret_env_json: '',
+      entrypoint: '',
+      args: '',
+      registry_username: '',
+      registry_secret: '',
+      currency: 'usdc',
+      enable_price_estimation: true,
+    },
+  })
+
+  const hardwareId = form.watch('hardware_id')
+  const gpuCount = toNumber(form.watch('gpus_per_container'), 1)
+  const locationIds = form.watch('location_ids')
+  const durationHours = toNumber(form.watch('duration_hours'), 1)
+  const replicaCount = toNumber(form.watch('replica_count'), 1)
+  const trafficPort = toNumber(form.watch('traffic_port'), DEFAULT_TRAFFIC_PORT)
+  const estimateEnabled = form.watch('enable_price_estimation')
+  const currency = form.watch('currency')
+  const resourceName = form.watch('resource_private_name')
+
+  const { data: hardwareTypesData, isLoading: isLoadingHardware } = useQuery({
+    queryKey: ['deployment-hardware-types'],
+    queryFn: getHardwareTypes,
+    enabled: open,
+  })
+
+  const hardwareOptions = useMemo(() => {
+    const items = hardwareTypesData?.data?.hardware_types || []
+    if (!Array.isArray(items)) return []
+    return items.map((h: Record<string, unknown>) => ({
+      label:
+        (h?.brand_name ? `${h.brand_name} ` : '') + String(h?.name ?? h?.id),
+      value: String(h?.id),
+      max_gpus: Number(h?.max_gpus || 1),
+    }))
+  }, [hardwareTypesData])
+
+  // Keep gpus_per_container <= max_gpus
+  useEffect(() => {
+    if (!hardwareId) return
+    const hw = hardwareOptions.find((x) => x.value === hardwareId)
+    if (!hw) return
+    const max =
+      Number.isFinite(hw.max_gpus) && hw.max_gpus > 0 ? hw.max_gpus : 1
+    if (gpuCount > max) {
+      form.setValue('gpus_per_container', max)
+    }
+  }, [hardwareId, hardwareOptions, gpuCount, form])
+
+  const { data: replicasData, isLoading: isLoadingReplicas } = useQuery({
+    queryKey: ['deployment-available-replicas', hardwareId, gpuCount],
+    queryFn: () =>
+      getAvailableReplicas({
+        hardware_id: hardwareId,
+        gpu_count: gpuCount,
+      }),
+    enabled: open && Boolean(hardwareId) && gpuCount > 0,
+  })
+
+  const locationOptions = useMemo(() => {
+    const replicas = replicasData?.data?.replicas || []
+    if (!Array.isArray(replicas)) return []
+    const map = new Map<string, { label: string; value: string }>()
+    replicas.forEach((r: Record<string, unknown>) => {
+      const id = (r?.location_id ??
+        (r?.location as Record<string, unknown>)?.id) as string | undefined
+      if (id === null || id === undefined) return
+      const name = (r?.location_name ??
+        (r?.location as Record<string, unknown>)?.name ??
+        r?.name ??
+        String(id)) as string
+      const key = String(id)
+      if (!map.has(key)) {
+        map.set(key, { label: String(name), value: key })
+      }
+    })
+    return Array.from(map.values())
+  }, [replicasData])
+
+  const { data: priceData, isLoading: isLoadingPrice } = useQuery({
+    queryKey: [
+      'deployment-price',
+      hardwareId,
+      gpuCount,
+      durationHours,
+      replicaCount,
+      locationIds,
+      currency,
+    ],
+    queryFn: () =>
+      estimatePrice({
+        location_ids: locationIds,
+        hardware_id: hardwareId,
+        gpus_per_container: gpuCount,
+        duration_hours: durationHours,
+        replica_count: replicaCount,
+        currency: currency || 'usdc',
+      }),
+    enabled:
+      open &&
+      estimateEnabled === true &&
+      Boolean(hardwareId) &&
+      gpuCount > 0 &&
+      durationHours > 0 &&
+      replicaCount > 0 &&
+      locationIds.length > 0,
+  })
+
+  const { data: nameCheckData, isFetching: isCheckingName } = useQuery({
+    queryKey: ['deployment-name-check', resourceName],
+    queryFn: async () => {
+      const name = (resourceName || '').trim()
+      if (!name) return null
+      return await checkClusterNameAvailability(name)
+    },
+    enabled: open && Boolean(resourceName && resourceName.trim().length > 0),
+    staleTime: 10_000,
+  })
+
+  const nameAvailable =
+    nameCheckData?.success === true ? nameCheckData?.data?.available : undefined
+
+  const createMutation = useMutation({
+    mutationFn: async (values: FormValues) => {
+      const env =
+        values.env_json && values.env_json.trim()
+          ? (JSON.parse(values.env_json) as Record<string, unknown>)
+          : undefined
+      const secretEnv =
+        values.secret_env_json && values.secret_env_json.trim()
+          ? (JSON.parse(values.secret_env_json) as Record<string, unknown>)
+          : undefined
+
+      const envVariables =
+        env && typeof env === 'object' && !Array.isArray(env)
+          ? (Object.fromEntries(
+              Object.entries(env).map(([k, v]) => [k, String(v)])
+            ) as Record<string, string>)
+          : undefined
+
+      const secretEnvVariables =
+        secretEnv && typeof secretEnv === 'object' && !Array.isArray(secretEnv)
+          ? (Object.fromEntries(
+              Object.entries(secretEnv).map(([k, v]) => [k, String(v)])
+            ) as Record<string, string>)
+          : undefined
+
+      const gpusPerContainer = Number(values.gpus_per_container)
+      const durationHoursVal = Number(values.duration_hours)
+      const replicaCountVal = Number(values.replica_count)
+
+      const entrypoint = values.entrypoint
+        ? values.entrypoint
+            .split(' ')
+            .map((x) => x.trim())
+            .filter(Boolean)
+        : undefined
+
+      const args = values.args
+        ? values.args
+            .split(' ')
+            .map((x) => x.trim())
+            .filter(Boolean)
+        : undefined
+
+      const location_ids = (values.location_ids || [])
+        .map((x) => Number(x))
+        .filter((n) => Number.isInteger(n) && n > 0)
+
+      const payload = {
+        resource_private_name: values.resource_private_name.trim(),
+        duration_hours: Number.isFinite(durationHoursVal)
+          ? durationHoursVal
+          : 1,
+        gpus_per_container: Number.isFinite(gpusPerContainer)
+          ? gpusPerContainer
+          : 1,
+        hardware_id: Number(values.hardware_id),
+        location_ids,
+        container_config: {
+          replica_count: Number.isFinite(replicaCountVal) ? replicaCountVal : 1,
+          traffic_port: Number.isFinite(trafficPort)
+            ? trafficPort
+            : DEFAULT_TRAFFIC_PORT,
+          ...(entrypoint?.length ? { entrypoint } : {}),
+          ...(args?.length ? { args } : {}),
+          ...(envVariables ? { env_variables: envVariables } : {}),
+          ...(secretEnvVariables
+            ? { secret_env_variables: secretEnvVariables }
+            : {}),
+        },
+        registry_config: {
+          image_url: values.image_url,
+          ...(values.registry_username?.trim()
+            ? { registry_username: values.registry_username.trim() }
+            : {}),
+          ...(values.registry_secret?.trim()
+            ? { registry_secret: values.registry_secret.trim() }
+            : {}),
+        },
+      }
+
+      return await createDeployment(payload)
+    },
+    onSuccess: (data) => {
+      if (data?.success) {
+        toast.success(t('Deployment created successfully'))
+        queryClient.invalidateQueries({
+          queryKey: deploymentsQueryKeys.lists(),
+        })
+        onOpenChange(false)
+        return
+      }
+      toast.error(data?.message || t('Failed to create deployment'))
+    },
+    onError: (err: Error) => {
+      toast.error(err.message || t('Failed to create deployment'))
+    },
+  })
+
+  // Reset form when opening
+  useEffect(() => {
+    if (!open) return
+    form.reset({
+      resource_private_name: '',
+      image_url: BUILTIN_IMAGE,
+      traffic_port: DEFAULT_TRAFFIC_PORT,
+      hardware_id: '',
+      gpus_per_container: 1,
+      location_ids: [],
+      replica_count: 1,
+      duration_hours: 1,
+      env_json: '',
+      secret_env_json: '',
+      entrypoint: '',
+      args: '',
+      registry_username: '',
+      registry_secret: '',
+      currency: 'usdc',
+      enable_price_estimation: true,
+    })
+  }, [open, form])
+
+  const priceSummary = useMemo(() => {
+    const est = priceData?.data
+    if (!est || typeof est !== 'object') return ''
+    const total =
+      (est as Record<string, unknown>)?.total_cost ??
+      (est as Record<string, unknown>)?.total ??
+      ''
+    const currency = (est as Record<string, unknown>)?.currency ?? ''
+    if (total === '' && currency === '') return ''
+    return `${total} ${currency}`.trim()
+  }, [priceData])
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='max-w-3xl'>
+        <DialogHeader>
+          <DialogTitle>{t('Create deployment')}</DialogTitle>
+        </DialogHeader>
+
+        <Form {...form}>
+          <form
+            onSubmit={form.handleSubmit((values) =>
+              createMutation.mutate(values)
+            )}
+            className='space-y-4'
+          >
+            <div className='grid gap-4 md:grid-cols-2'>
+              <FormField
+                control={form.control}
+                name='resource_private_name'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Container name')}</FormLabel>
+                    <FormControl>
+                      <Input placeholder={t('Enter a name')} {...field} />
+                    </FormControl>
+                    {open && field.value?.trim() ? (
+                      <div className='text-muted-foreground mt-1 text-xs'>
+                        {isCheckingName
+                          ? t('Checking name...')
+                          : nameAvailable === true
+                            ? t('Name is available')
+                            : nameAvailable === false
+                              ? t('Name is not available')
+                              : ''}
+                      </div>
+                    ) : null}
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='hardware_id'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Hardware type')}</FormLabel>
+                    <Select
+                      value={field.value}
+                      onValueChange={(v) => field.onChange(v)}
+                      disabled={isLoadingHardware}
+                    >
+                      <FormControl>
+                        <SelectTrigger>
+                          <SelectValue placeholder={t('Select')} />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        {hardwareOptions.map((opt) => (
+                          <SelectItem key={opt.value} value={opt.value}>
+                            {opt.label}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </div>
+
+            <div className='grid gap-4 md:grid-cols-2'>
+              <FormField
+                control={form.control}
+                name='location_ids'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Deployment location')}</FormLabel>
+                    <FormControl>
+                      <MultiSelect
+                        options={locationOptions}
+                        selected={(field.value || []) as string[]}
+                        onChange={(vals) => {
+                          if (isLoadingReplicas || !hardwareId) return
+                          field.onChange(vals)
+                        }}
+                        placeholder={
+                          isLoadingReplicas
+                            ? t('Loading...')
+                            : t('Select locations')
+                        }
+                        className={
+                          isLoadingReplicas || !hardwareId
+                            ? 'pointer-events-none opacity-60'
+                            : ''
+                        }
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='gpus_per_container'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('GPU count')}</FormLabel>
+                    <FormControl>
+                      <Input
+                        type='number'
+                        value={toNumber(field.value, gpuCount)}
+                        onChange={(e) =>
+                          field.onChange(
+                            e.target.value === '' ? 0 : Number(e.target.value)
+                          )
+                        }
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </div>
+
+            <div className='grid gap-4 md:grid-cols-3'>
+              <FormField
+                control={form.control}
+                name='replica_count'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Replica count')}</FormLabel>
+                    <FormControl>
+                      <Input
+                        type='number'
+                        value={toNumber(field.value, replicaCount)}
+                        onChange={(e) =>
+                          field.onChange(
+                            e.target.value === '' ? 0 : Number(e.target.value)
+                          )
+                        }
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+              <FormField
+                control={form.control}
+                name='duration_hours'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Duration (hours)')}</FormLabel>
+                    <FormControl>
+                      <Input
+                        type='number'
+                        value={toNumber(field.value, durationHours)}
+                        onChange={(e) =>
+                          field.onChange(
+                            e.target.value === '' ? 0 : Number(e.target.value)
+                          )
+                        }
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+              <FormField
+                control={form.control}
+                name='traffic_port'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Port')}</FormLabel>
+                    <FormControl>
+                      <Input
+                        type='number'
+                        value={toNumber(field.value, trafficPort)}
+                        onChange={(e) =>
+                          field.onChange(
+                            e.target.value === '' ? 0 : Number(e.target.value)
+                          )
+                        }
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </div>
+
+            <div className='grid gap-4 md:grid-cols-2'>
+              <FormField
+                control={form.control}
+                name='image_url'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Image')}</FormLabel>
+                    <FormControl>
+                      <Input {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+              <FormField
+                control={form.control}
+                name='enable_price_estimation'
+                render={({ field }) => (
+                  <FormItem className='flex items-center justify-between rounded-md border p-3'>
+                    <div className='space-y-0.5'>
+                      <FormLabel>{t('Price estimation')}</FormLabel>
+                      <div className='text-muted-foreground text-sm'>
+                        {isLoadingPrice
+                          ? t('Calculating...')
+                          : priceSummary || t('Not calculated')}
+                      </div>
+                    </div>
+                    <FormControl>
+                      <Switch
+                        checked={field.value === true}
+                        onCheckedChange={field.onChange}
+                      />
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+            </div>
+
+            <details className='rounded-md border p-3'>
+              <summary className='cursor-pointer text-sm'>
+                {t('Advanced configuration')}
+              </summary>
+              <div className='mt-3 grid gap-4 md:grid-cols-2'>
+                <div className='space-y-2'>
+                  <Label>{t('Currency')}</Label>
+                  <FormField
+                    control={form.control}
+                    name='currency'
+                    render={({ field }) => (
+                      <Select
+                        value={field.value || 'usdc'}
+                        onValueChange={(v) => field.onChange(v)}
+                      >
+                        <SelectTrigger>
+                          <SelectValue placeholder={t('Select')} />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value='usdc'>USDC</SelectItem>
+                          <SelectItem value='usd'>USD</SelectItem>
+                        </SelectContent>
+                      </Select>
+                    )}
+                  />
+                  <div className='text-muted-foreground text-xs'>
+                    {t('Used for price estimation only')}
+                  </div>
+                </div>
+
+                <FormField
+                  control={form.control}
+                  name='env_json'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Environment variables (JSON)')}</FormLabel>
+                      <FormControl>
+                        <Textarea
+                          className='min-h-32 font-mono text-xs'
+                          placeholder='{"KEY":"VALUE"}'
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+                <FormField
+                  control={form.control}
+                  name='secret_env_json'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>
+                        {t('Secret environment variables (JSON)')}
+                      </FormLabel>
+                      <FormControl>
+                        <Textarea
+                          className='min-h-32 font-mono text-xs'
+                          placeholder='{"SECRET":"VALUE"}'
+                          {...field}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name='entrypoint'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
+                      <FormControl>
+                        <Input placeholder='bash -lc' {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name='args'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Args (space separated)')}</FormLabel>
+                      <FormControl>
+                        <Input placeholder='--foo bar' {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name='registry_username'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Registry username')}</FormLabel>
+                      <FormControl>
+                        <Input autoComplete='off' {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={form.control}
+                  name='registry_secret'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Registry secret')}</FormLabel>
+                      <FormControl>
+                        <Input type='password' autoComplete='off' {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </div>
+            </details>
+
+            <div className='flex justify-end gap-2'>
+              <Button
+                type='button'
+                variant='outline'
+                onClick={() => onOpenChange(false)}
+              >
+                {t('Cancel')}
+              </Button>
+              <Button type='submit' disabled={createMutation.isPending}>
+                {createMutation.isPending ? t('Submitting...') : t('Create')}
+              </Button>
+            </div>
+          </form>
+        </Form>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 218 - 0
web/src/features/models/components/dialogs/extend-deployment-dialog.tsx

@@ -0,0 +1,218 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Separator } from '@/components/ui/separator'
+import { estimatePrice, extendDeployment, getDeployment } from '../../api'
+import { deploymentsQueryKeys } from '../../lib'
+
+function toInt(value: unknown, fallback: number) {
+  const n = typeof value === 'number' ? value : Number(value)
+  return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback
+}
+
+export function ExtendDeploymentDialog({
+  open,
+  onOpenChange,
+  deploymentId,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  deploymentId: string | number | null
+}) {
+  const { t } = useTranslation()
+  const queryClient = useQueryClient()
+  const [hours, setHours] = useState(1)
+  const [isSubmitting, setIsSubmitting] = useState(false)
+
+  useEffect(() => {
+    if (open) setHours(1)
+  }, [open])
+
+  const { data: detailsRes, isLoading: isLoadingDetails } = useQuery({
+    queryKey: ['deployment-details-for-extend', deploymentId],
+    queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
+    enabled: open && deploymentId !== null,
+  })
+
+  const details = detailsRes?.data
+
+  const priceParams = useMemo(() => {
+    if (!details) return null
+    const hardwareId = toInt(details.hardware_id, 0)
+    const gpusPerContainer = toInt(details.gpus_per_container, 0)
+    const replicaCount = toInt(details.total_containers, 0)
+    const locations = Array.isArray(details.locations) ? details.locations : []
+    const locationIds = locations
+      .map((x) => {
+        if (!x || typeof x !== 'object') return 0
+        return toInt((x as Record<string, unknown>)?.id, 0)
+      })
+      .filter((x) => x > 0)
+
+    if (
+      hardwareId <= 0 ||
+      gpusPerContainer <= 0 ||
+      replicaCount <= 0 ||
+      locationIds.length === 0
+    ) {
+      return null
+    }
+
+    return {
+      hardware_id: hardwareId,
+      gpus_per_container: gpusPerContainer,
+      replica_count: replicaCount,
+      location_ids: locationIds,
+    }
+  }, [details])
+
+  const {
+    data: priceRes,
+    isLoading: isLoadingPrice,
+    isFetching: isFetchingPrice,
+  } = useQuery({
+    queryKey: ['deployment-extend-price', deploymentId, hours, priceParams],
+    queryFn: () =>
+      priceParams
+        ? estimatePrice({
+            location_ids: priceParams.location_ids,
+            hardware_id: priceParams.hardware_id,
+            gpus_per_container: priceParams.gpus_per_container,
+            replica_count: priceParams.replica_count,
+            duration_hours: hours,
+            currency: 'usdc',
+          })
+        : null,
+    enabled: open && Boolean(priceParams) && hours > 0,
+  })
+
+  const priceSummary = useMemo(() => {
+    const data = priceRes?.data
+    if (!data || typeof data !== 'object') return ''
+    const record = data as Record<string, unknown>
+    const breakdown = record.price_breakdown
+    let total: unknown = record.total_cost
+    if (
+      breakdown &&
+      typeof breakdown === 'object' &&
+      !Array.isArray(breakdown)
+    ) {
+      const b = breakdown as Record<string, unknown>
+      total = b.total_cost ?? b.totalCost ?? b.TotalCost ?? total
+    }
+    const currency = record.currency ?? 'USDC'
+    if (total === undefined || total === null) return ''
+    return `${String(total)} ${String(currency).toUpperCase()}`.trim()
+  }, [priceRes])
+
+  const canSubmit = Boolean(deploymentId) && hours > 0 && !isSubmitting
+
+  const onSubmit = async () => {
+    if (!deploymentId) return
+    const h = toInt(hours, 1)
+    if (h <= 0) {
+      toast.error(t('Please enter a valid duration'))
+      return
+    }
+    setIsSubmitting(true)
+    try {
+      const res = await extendDeployment(deploymentId, h)
+      if (res.success) {
+        toast.success(t('Extended successfully'))
+        queryClient.invalidateQueries({
+          queryKey: deploymentsQueryKeys.lists(),
+        })
+        queryClient.invalidateQueries({ queryKey: ['deployment-details'] })
+        onOpenChange(false)
+        return
+      }
+      toast.error(res.message || t('Extend failed'))
+    } catch (err: unknown) {
+      toast.error(err instanceof Error ? err.message : t('Extend failed'))
+    } finally {
+      setIsSubmitting(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='sm:max-w-lg'>
+        <DialogHeader>
+          <DialogTitle>{t('Extend deployment')}</DialogTitle>
+        </DialogHeader>
+
+        {isLoadingDetails ? (
+          <div className='flex items-center justify-center py-10'>
+            <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
+          </div>
+        ) : (
+          <div className='space-y-4'>
+            <div className='text-muted-foreground text-sm'>
+              {t('Deployment ID')}:{' '}
+              <span className='font-mono'>{deploymentId}</span>
+            </div>
+
+            <div className='space-y-2'>
+              <div className='text-sm font-medium'>{t('Duration (hours)')}</div>
+              <Input
+                type='number'
+                min={1}
+                value={hours}
+                onChange={(e) => setHours(toInt(e.target.value, 1))}
+              />
+              <div className='text-muted-foreground text-xs'>
+                {t('This will extend the deployment by the specified hours.')}
+              </div>
+            </div>
+
+            <Separator />
+
+            <div className='space-y-1'>
+              <div className='text-sm font-medium'>{t('Estimated cost')}</div>
+              <div className='text-muted-foreground text-sm'>
+                {isLoadingPrice || isFetchingPrice ? (
+                  <span className='inline-flex items-center gap-2'>
+                    <Loader2 className='h-4 w-4 animate-spin' />
+                    {t('Calculating...')}
+                  </span>
+                ) : priceParams ? (
+                  priceSummary || t('Not available')
+                ) : (
+                  t('Not available')
+                )}
+              </div>
+              {!priceParams ? (
+                <div className='text-muted-foreground text-xs'>
+                  {t('Unable to estimate price for this deployment.')}
+                </div>
+              ) : null}
+            </div>
+          </div>
+        )}
+
+        <DialogFooter className='mt-4'>
+          <Button variant='outline' onClick={() => onOpenChange(false)}>
+            {t('Cancel')}
+          </Button>
+          <Button onClick={() => void onSubmit()} disabled={!canSubmit}>
+            {isSubmitting ? (
+              <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+            ) : null}
+            {t('Extend')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 130 - 0
web/src/features/models/components/dialogs/rename-deployment-dialog.tsx

@@ -0,0 +1,130 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
+import { deploymentsQueryKeys } from '../../lib'
+
+export function RenameDeploymentDialog({
+  open,
+  onOpenChange,
+  deploymentId,
+  currentName,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  deploymentId: string | number | null
+  currentName?: string
+}) {
+  const { t } = useTranslation()
+  const queryClient = useQueryClient()
+  const [name, setName] = useState(currentName || '')
+  const [isSubmitting, setIsSubmitting] = useState(false)
+
+  useEffect(() => {
+    if (open) setName(currentName || '')
+  }, [open, currentName])
+
+  const trimmed = name.trim()
+
+  const { data: checkRes, isFetching: isChecking } = useQuery({
+    queryKey: ['deployment-rename-check', trimmed],
+    queryFn: () => (trimmed ? checkClusterNameAvailability(trimmed) : null),
+    enabled: open && Boolean(trimmed),
+    staleTime: 10_000,
+  })
+
+  const available =
+    checkRes?.success === true ? checkRes?.data?.available : undefined
+
+  const helper = useMemo(() => {
+    if (!trimmed) return t('Enter a new name')
+    if (isChecking) return t('Checking name...')
+    if (available === true) return t('Name is available')
+    if (available === false) return t('Name is not available')
+    return ''
+  }, [available, isChecking, t, trimmed])
+
+  const canSubmit =
+    Boolean(deploymentId) &&
+    Boolean(trimmed) &&
+    available !== false &&
+    !isSubmitting
+
+  const onSubmit = async () => {
+    if (!deploymentId) return
+    if (!trimmed) {
+      toast.error(t('Please enter a name'))
+      return
+    }
+    if (available === false) {
+      toast.error(t('Name is not available'))
+      return
+    }
+
+    setIsSubmitting(true)
+    try {
+      const res = await updateDeploymentName(deploymentId, trimmed)
+      if (res.success) {
+        toast.success(t('Renamed successfully'))
+        queryClient.invalidateQueries({
+          queryKey: deploymentsQueryKeys.lists(),
+        })
+        queryClient.invalidateQueries({ queryKey: ['deployment-details'] })
+        onOpenChange(false)
+        return
+      }
+      toast.error(res.message || t('Rename failed'))
+    } catch (err: unknown) {
+      toast.error(err instanceof Error ? err.message : t('Rename failed'))
+    } finally {
+      setIsSubmitting(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='sm:max-w-lg'>
+        <DialogHeader>
+          <DialogTitle>{t('Rename deployment')}</DialogTitle>
+        </DialogHeader>
+
+        <div className='space-y-2'>
+          <div className='text-muted-foreground text-sm'>
+            {t('Deployment ID')}:{' '}
+            <span className='font-mono'>{deploymentId}</span>
+          </div>
+          <Input
+            placeholder={t('Enter a new name')}
+            value={name}
+            onChange={(e) => setName(e.target.value)}
+            autoComplete='off'
+          />
+          <div className='text-muted-foreground text-xs'>{helper}</div>
+        </div>
+
+        <DialogFooter className='mt-4'>
+          <Button variant='outline' onClick={() => onOpenChange(false)}>
+            {t('Cancel')}
+          </Button>
+          <Button onClick={() => void onSubmit()} disabled={!canSubmit}>
+            {isSubmitting ? (
+              <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+            ) : null}
+            {t('Rename')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 408 - 0
web/src/features/models/components/dialogs/update-config-dialog.tsx

@@ -0,0 +1,408 @@
+import { useEffect, useMemo } from 'react'
+import { z } from 'zod'
+import { useForm, type Resolver } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { getDeployment, updateDeployment } from '../../api'
+import { deploymentsQueryKeys } from '../../lib'
+
+const schema = z.object({
+  image_url: z.string().optional(),
+  traffic_port: z.coerce.number().int().min(1).max(65535).optional(),
+  entrypoint: z.string().optional(),
+  args: z.string().optional(),
+  command: z.string().optional(),
+  registry_username: z.string().optional(),
+  registry_secret: z.string().optional(),
+  env_json: z.string().optional(),
+  secret_env_json: z.string().optional(),
+})
+
+type Values = z.input<typeof schema>
+
+function normalizeJsonObject(input?: string) {
+  if (!input || !input.trim()) return undefined
+  const parsed = JSON.parse(input)
+  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    throw new Error('JSON must be an object')
+  }
+  return Object.fromEntries(
+    Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
+      k,
+      String(v),
+    ])
+  ) as Record<string, string>
+}
+
+export function UpdateConfigDialog({
+  open,
+  onOpenChange,
+  deploymentId,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  deploymentId: string | number | null
+}) {
+  const { t } = useTranslation()
+  const queryClient = useQueryClient()
+
+  const form = useForm<Values>({
+    resolver: zodResolver(schema) as unknown as Resolver<Values>,
+    defaultValues: {
+      image_url: '',
+      traffic_port: undefined,
+      entrypoint: '',
+      args: '',
+      command: '',
+      registry_username: '',
+      registry_secret: '',
+      env_json: '',
+      secret_env_json: '',
+    },
+  })
+
+  const { data: detailsRes, isLoading } = useQuery({
+    queryKey: ['deployment-details-for-update', deploymentId],
+    queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
+    enabled: open && deploymentId !== null,
+  })
+
+  const details = detailsRes?.data
+
+  useEffect(() => {
+    if (!open || !details) return
+    const containerConfig =
+      details.container_config && typeof details.container_config === 'object'
+        ? (details.container_config as Record<string, unknown>)
+        : {}
+    const imageUrl =
+      typeof containerConfig.image_url === 'string'
+        ? containerConfig.image_url
+        : ''
+    const trafficPort =
+      typeof containerConfig.traffic_port === 'number'
+        ? containerConfig.traffic_port
+        : undefined
+    const entrypointArr = Array.isArray(containerConfig.entrypoint)
+      ? (containerConfig.entrypoint as unknown[])
+          .map((x) => (typeof x === 'string' ? x : ''))
+          .filter(Boolean)
+      : []
+    const envVars =
+      containerConfig.env_variables &&
+      typeof containerConfig.env_variables === 'object' &&
+      !Array.isArray(containerConfig.env_variables)
+        ? (containerConfig.env_variables as Record<string, unknown>)
+        : {}
+
+    form.reset({
+      image_url: imageUrl,
+      traffic_port: trafficPort,
+      entrypoint: entrypointArr.join(' '),
+      args: '',
+      command: '',
+      registry_username: '',
+      registry_secret: '',
+      env_json: Object.keys(envVars).length
+        ? JSON.stringify(envVars, null, 2)
+        : '',
+      secret_env_json: '',
+    })
+  }, [open, details, form])
+
+  const title = useMemo(
+    () =>
+      deploymentId
+        ? `${t('Update configuration')} - ${deploymentId}`
+        : t('Update configuration'),
+    [deploymentId, t]
+  )
+
+  const onSubmit = async (values: Values) => {
+    if (!deploymentId) return
+    try {
+      const env_variables = normalizeJsonObject(values.env_json)
+      const secret_env_variables = normalizeJsonObject(values.secret_env_json)
+      const entrypoint = values.entrypoint
+        ? values.entrypoint
+            .split(' ')
+            .map((x) => x.trim())
+            .filter(Boolean)
+        : undefined
+      const args = values.args
+        ? values.args
+            .split(' ')
+            .map((x) => x.trim())
+            .filter(Boolean)
+        : undefined
+
+      const res = await updateDeployment(deploymentId, {
+        image_url: values.image_url?.trim() || undefined,
+        traffic_port:
+          typeof values.traffic_port === 'number'
+            ? values.traffic_port
+            : undefined,
+        registry_username: values.registry_username?.trim() || undefined,
+        registry_secret: values.registry_secret?.trim() || undefined,
+        command: values.command?.trim() || undefined,
+        ...(entrypoint?.length ? { entrypoint } : {}),
+        ...(args?.length ? { args } : {}),
+        ...(env_variables ? { env_variables } : {}),
+        ...(secret_env_variables ? { secret_env_variables } : {}),
+      })
+
+      if (res.success) {
+        toast.success(t('Updated successfully'))
+        queryClient.invalidateQueries({
+          queryKey: deploymentsQueryKeys.lists(),
+        })
+        queryClient.invalidateQueries({ queryKey: ['deployment-details'] })
+        onOpenChange(false)
+        return
+      }
+      toast.error(res.message || t('Update failed'))
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : t('Update failed')
+      toast.error(msg)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
+        <DialogHeader>
+          <DialogTitle>{title}</DialogTitle>
+        </DialogHeader>
+
+        {isLoading ? (
+          <div className='flex items-center justify-center py-10'>
+            <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
+          </div>
+        ) : (
+          <div className='max-h-[72vh] overflow-y-auto py-2 pr-1'>
+            <Form {...form}>
+              <form
+                onSubmit={form.handleSubmit(onSubmit)}
+                autoComplete='off'
+                className='space-y-4'
+              >
+                <div className='grid gap-4 md:grid-cols-2'>
+                  <FormField
+                    control={form.control}
+                    name='image_url'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>{t('Image')}</FormLabel>
+                        <FormControl>
+                          <Input
+                            placeholder='ollama/ollama:latest'
+                            {...field}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={form.control}
+                    name='traffic_port'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>{t('Port')}</FormLabel>
+                        <FormControl>
+                          <Input
+                            type='number'
+                            min={1}
+                            max={65535}
+                            value={
+                              typeof field.value === 'number' ||
+                              typeof field.value === 'string'
+                                ? field.value
+                                : ''
+                            }
+                            onChange={(e) => {
+                              const v = e.target.value
+                              field.onChange(v === '' ? undefined : Number(v))
+                            }}
+                            onBlur={field.onBlur}
+                            name={field.name}
+                            ref={field.ref}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <div className='grid gap-4 md:grid-cols-2'>
+                  <FormField
+                    control={form.control}
+                    name='entrypoint'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>
+                          {t('Entrypoint (space separated)')}
+                        </FormLabel>
+                        <FormControl>
+                          <Input placeholder='bash -lc' {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={form.control}
+                    name='args'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>{t('Args (space separated)')}</FormLabel>
+                        <FormControl>
+                          <Input placeholder='--foo bar' {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <FormField
+                  control={form.control}
+                  name='command'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>{t('Command')}</FormLabel>
+                      <FormControl>
+                        <Input placeholder='Optional' {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <details className='rounded-md border p-3'>
+                  <summary className='cursor-pointer text-sm'>
+                    {t('Registry (optional)')}
+                  </summary>
+                  <div className='mt-3 grid gap-4 md:grid-cols-2'>
+                    <FormField
+                      control={form.control}
+                      name='registry_username'
+                      render={({ field }) => (
+                        <FormItem>
+                          <FormLabel>{t('Registry username')}</FormLabel>
+                          <FormControl>
+                            <Input autoComplete='off' {...field} />
+                          </FormControl>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+                    <FormField
+                      control={form.control}
+                      name='registry_secret'
+                      render={({ field }) => (
+                        <FormItem>
+                          <FormLabel>{t('Registry secret')}</FormLabel>
+                          <FormControl>
+                            <Input
+                              type='password'
+                              autoComplete='off'
+                              {...field}
+                            />
+                          </FormControl>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+                  </div>
+                </details>
+
+                <details className='rounded-md border p-3'>
+                  <summary className='cursor-pointer text-sm'>
+                    {t('Environment variables')}
+                  </summary>
+                  <div className='mt-3 grid gap-4 md:grid-cols-2'>
+                    <FormField
+                      control={form.control}
+                      name='env_json'
+                      render={({ field }) => (
+                        <FormItem>
+                          <FormLabel>{t('Env (JSON object)')}</FormLabel>
+                          <FormControl>
+                            <Textarea
+                              className='min-h-40 font-mono text-xs'
+                              placeholder='{"KEY":"VALUE"}'
+                              {...field}
+                            />
+                          </FormControl>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+                    <FormField
+                      control={form.control}
+                      name='secret_env_json'
+                      render={({ field }) => (
+                        <FormItem>
+                          <FormLabel>{t('Secret env (JSON object)')}</FormLabel>
+                          <FormControl>
+                            <Textarea
+                              className='min-h-40 font-mono text-xs'
+                              placeholder='{"SECRET":"VALUE"}'
+                              {...field}
+                            />
+                          </FormControl>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+                  </div>
+                </details>
+
+                <DialogFooter className='pt-2'>
+                  <Button
+                    type='button'
+                    variant='outline'
+                    onClick={() => onOpenChange(false)}
+                  >
+                    {t('Cancel')}
+                  </Button>
+                  <Button type='submit' disabled={form.formState.isSubmitting}>
+                    {form.formState.isSubmitting ? (
+                      <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                    ) : null}
+                    {t('Update')}
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 255 - 0
web/src/features/models/components/dialogs/view-details-dialog.tsx

@@ -0,0 +1,255 @@
+import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Copy, ExternalLink, Loader2, RefreshCcw } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Separator } from '@/components/ui/separator'
+import { getDeployment, listDeploymentContainers } from '../../api'
+
+export function ViewDetailsDialog({
+  open,
+  onOpenChange,
+  deploymentId,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  deploymentId: string | number | null
+}) {
+  const { t } = useTranslation()
+
+  const {
+    data: detailsRes,
+    isLoading: isLoadingDetails,
+    refetch: refetchDetails,
+    isFetching: isFetchingDetails,
+  } = useQuery({
+    queryKey: ['deployment-details', deploymentId],
+    queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
+    enabled: open && deploymentId !== null,
+  })
+
+  const {
+    data: containersRes,
+    isLoading: isLoadingContainers,
+    refetch: refetchContainers,
+    isFetching: isFetchingContainers,
+  } = useQuery({
+    queryKey: ['deployment-details-containers', deploymentId],
+    queryFn: () =>
+      deploymentId ? listDeploymentContainers(deploymentId) : null,
+    enabled: open && deploymentId !== null,
+  })
+
+  const details = detailsRes?.data
+  const containers = useMemo(() => {
+    const items = containersRes?.data?.containers
+    return Array.isArray(items) ? items : []
+  }, [containersRes?.data?.containers])
+
+  const locations = useMemo(() => {
+    const items = details?.locations
+    if (!Array.isArray(items)) return []
+    return items
+      .map((x) => {
+        if (!x || typeof x !== 'object') return null
+        const name = (x as Record<string, unknown>)?.name
+        const iso2 = (x as Record<string, unknown>)?.iso2
+        const id = (x as Record<string, unknown>)?.id
+        return `${String(name ?? id ?? '')}${iso2 ? ` (${iso2})` : ''}`.trim()
+      })
+      .filter(Boolean) as string[]
+  }, [details])
+
+  const handleCopyId = async () => {
+    if (deploymentId === null || deploymentId === undefined) return
+    try {
+      await navigator.clipboard.writeText(String(deploymentId))
+      toast.success(t('Copied'))
+    } catch {
+      toast.error(t('Copy failed'))
+    }
+  }
+
+  const handleRefresh = () => {
+    refetchDetails()
+    refetchContainers()
+  }
+
+  const payloadJson = useMemo(() => {
+    if (!details) return ''
+    try {
+      return JSON.stringify(details, null, 2)
+    } catch {
+      return ''
+    }
+  }, [details])
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
+        <DialogHeader>
+          <DialogTitle>{t('Deployment details')}</DialogTitle>
+        </DialogHeader>
+
+        <div className='max-h-[72vh] space-y-4 overflow-y-auto py-2 pr-1'>
+          <div className='flex flex-wrap items-center justify-between gap-2'>
+            <div className='text-muted-foreground text-sm'>
+              {t('Deployment ID')}:{' '}
+              <span className='font-mono'>{deploymentId}</span>
+            </div>
+            <div className='flex items-center gap-2'>
+              <Button variant='outline' size='sm' onClick={handleCopyId}>
+                <Copy className='mr-2 h-4 w-4' />
+                {t('Copy')}
+              </Button>
+              <Button
+                variant='outline'
+                size='sm'
+                onClick={handleRefresh}
+                disabled={isFetchingDetails || isFetchingContainers}
+              >
+                {isFetchingDetails || isFetchingContainers ? (
+                  <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                ) : (
+                  <RefreshCcw className='mr-2 h-4 w-4' />
+                )}
+                {t('Refresh')}
+              </Button>
+            </div>
+          </div>
+
+          <Separator />
+
+          {isLoadingDetails || isLoadingContainers ? (
+            <div className='flex items-center justify-center py-10'>
+              <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
+            </div>
+          ) : !detailsRes?.success ? (
+            <div className='text-muted-foreground py-10 text-center text-sm'>
+              {detailsRes?.message || t('Failed to fetch deployment details')}
+            </div>
+          ) : (
+            <>
+              <div className='grid gap-3 sm:grid-cols-2'>
+                <div className='rounded-lg border p-3'>
+                  <div className='text-muted-foreground text-xs'>
+                    {t('Status')}
+                  </div>
+                  <div className='mt-1 font-medium'>
+                    {String(details?.status ?? '-')}
+                  </div>
+                </div>
+                <div className='rounded-lg border p-3'>
+                  <div className='text-muted-foreground text-xs'>
+                    {t('Hardware')}
+                  </div>
+                  <div className='mt-1 font-medium'>
+                    {String(details?.brand_name ?? '')}{' '}
+                    {String(details?.hardware_name ?? '')}
+                  </div>
+                </div>
+                <div className='rounded-lg border p-3'>
+                  <div className='text-muted-foreground text-xs'>
+                    {t('Total GPUs')}
+                  </div>
+                  <div className='mt-1 font-medium'>
+                    {String(
+                      details?.total_gpus ?? details?.hardware_qty ?? '-'
+                    )}
+                  </div>
+                </div>
+                <div className='rounded-lg border p-3'>
+                  <div className='text-muted-foreground text-xs'>
+                    {t('Containers')}
+                  </div>
+                  <div className='mt-1 font-medium'>{containers.length}</div>
+                </div>
+              </div>
+
+              {locations.length ? (
+                <div className='rounded-lg border p-3'>
+                  <div className='text-muted-foreground text-xs'>
+                    {t('Locations')}
+                  </div>
+                  <div className='mt-1 flex flex-wrap gap-2 text-sm'>
+                    {locations.map((x) => (
+                      <span key={x} className='bg-muted rounded-md px-2 py-1'>
+                        {x}
+                      </span>
+                    ))}
+                  </div>
+                </div>
+              ) : null}
+
+              {containers.length ? (
+                <div className='rounded-lg border p-3'>
+                  <div className='text-muted-foreground mb-2 text-xs'>
+                    {t('Containers')}
+                  </div>
+                  <div className='space-y-2'>
+                    {containers.map((c) => {
+                      const id = c?.container_id
+                      if (typeof id !== 'string' || !id) return null
+                      const status =
+                        typeof c?.status === 'string' ? c.status : undefined
+                      const url =
+                        typeof c?.public_url === 'string' ? c.public_url : ''
+                      return (
+                        <div
+                          key={id}
+                          className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
+                        >
+                          <div className='min-w-0'>
+                            <div className='truncate font-mono text-sm'>
+                              {id}
+                            </div>
+                            <div className='text-muted-foreground text-xs'>
+                              {status ? `${t('Status')}: ${status}` : ''}
+                            </div>
+                          </div>
+                          {url ? (
+                            <Button
+                              variant='outline'
+                              size='sm'
+                              onClick={() => window.open(url, '_blank')}
+                            >
+                              <ExternalLink className='mr-2 h-4 w-4' />
+                              {t('Open')}
+                            </Button>
+                          ) : null}
+                        </div>
+                      )
+                    })}
+                  </div>
+                </div>
+              ) : null}
+
+              <details className='rounded-lg border p-3'>
+                <summary className='cursor-pointer text-sm font-medium'>
+                  {t('Raw JSON')}
+                </summary>
+                <pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
+                  {payloadJson || '-'}
+                </pre>
+              </details>
+            </>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button variant='outline' onClick={() => onOpenChange(false)}>
+            {t('Close')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 268 - 0
web/src/features/models/components/dialogs/view-logs-dialog.tsx

@@ -0,0 +1,268 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Switch } from '@/components/ui/switch'
+import { getDeploymentLogs, listDeploymentContainers } from '../../api'
+
+interface ViewLogsDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  deploymentId: string | number | null
+}
+
+export function ViewLogsDialog({
+  open,
+  onOpenChange,
+  deploymentId,
+}: ViewLogsDialogProps) {
+  const { t } = useTranslation()
+  const scrollRef = useRef<HTMLDivElement>(null)
+  const [autoScroll, setAutoScroll] = useState(true)
+  const [autoRefresh, setAutoRefresh] = useState(false)
+  const [stream, setStream] = useState<'stdout' | 'stderr' | 'all'>('stdout')
+  const [containerId, setContainerId] = useState<string>('')
+
+  const {
+    data: containersData,
+    isLoading: isLoadingContainers,
+    refetch: refetchContainers,
+    isFetching: isFetchingContainers,
+  } = useQuery({
+    queryKey: ['deployment-containers', deploymentId],
+    queryFn: () =>
+      deploymentId ? listDeploymentContainers(deploymentId) : null,
+    enabled: open && deploymentId !== null,
+  })
+
+  const containers = useMemo(() => {
+    const items = containersData?.data?.containers
+    return Array.isArray(items) ? items : []
+  }, [containersData?.data?.containers])
+
+  useEffect(() => {
+    if (!open) {
+      setContainerId('')
+      setStream('stdout')
+      setAutoScroll(true)
+      setAutoRefresh(false)
+      return
+    }
+
+    if (open && containers.length > 0 && !containerId) {
+      const first = containers[0]?.container_id
+      if (typeof first === 'string' && first) {
+        setContainerId(first)
+      }
+    }
+  }, [open, containers, containerId])
+
+  const {
+    data: logsData,
+    isLoading: isLoadingLogs,
+    refetch: refetchLogs,
+    isFetching: isFetchingLogs,
+  } = useQuery({
+    queryKey: ['deployment-logs', deploymentId, containerId, stream],
+    queryFn: () =>
+      deploymentId && containerId
+        ? getDeploymentLogs(deploymentId, {
+            container_id: containerId,
+            stream,
+            limit: 500,
+          })
+        : null,
+    enabled: open && deploymentId !== null && Boolean(containerId),
+    refetchInterval: open && autoRefresh ? 5000 : false,
+  })
+
+  const logsText = useMemo(() => {
+    const raw = logsData?.data
+    return typeof raw === 'string' ? raw : ''
+  }, [logsData?.data])
+
+  const logLines = useMemo(() => {
+    const normalized = logsText.replace(/\r\n?/g, '\n')
+    return normalized ? normalized.split('\n') : []
+  }, [logsText])
+
+  // Auto-scroll to bottom
+  useEffect(() => {
+    if (autoScroll && scrollRef.current) {
+      scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+    }
+  }, [logLines, autoScroll])
+
+  const handleDownload = () => {
+    if (!logsText.trim()) return
+    const blob = new Blob([logsText], { type: 'text/plain' })
+    const url = URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = url
+    a.download = `deployment-${deploymentId}-${containerId || 'logs'}.txt`
+    a.click()
+    URL.revokeObjectURL(url)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='flex h-[80vh] max-w-4xl flex-col'>
+        <DialogHeader>
+          <DialogTitle className='flex items-center gap-2'>
+            <Terminal className='h-5 w-5' />
+            {t('Deployment logs')}
+          </DialogTitle>
+        </DialogHeader>
+
+        <div className='mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
+          <div className='text-muted-foreground text-sm'>
+            {t('Deployment ID')}: {deploymentId}
+          </div>
+          <div className='flex flex-wrap items-center gap-2'>
+            <Button
+              variant='outline'
+              size='sm'
+              onClick={() => {
+                refetchContainers()
+                refetchLogs()
+              }}
+              disabled={isFetchingLogs || isFetchingContainers}
+            >
+              {isFetchingLogs || isFetchingContainers ? (
+                <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+              ) : (
+                <RefreshCcw className='mr-2 h-4 w-4' />
+              )}
+              {t('Refresh')}
+            </Button>
+            <Button
+              variant='outline'
+              size='sm'
+              onClick={handleDownload}
+              disabled={!logsText.trim()}
+            >
+              <Download className='mr-2 h-4 w-4' />
+              {t('Download')}
+            </Button>
+            <div className='flex items-center gap-2 rounded-md border px-3 py-1.5'>
+              <span className='text-xs'>{t('Auto refresh')}</span>
+              <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
+            </div>
+          </div>
+        </div>
+
+        <div className='mb-3 grid gap-3 sm:grid-cols-2'>
+          <div className='space-y-1'>
+            <div className='text-muted-foreground text-xs'>
+              {t('Container')}
+            </div>
+            <Select
+              value={containerId}
+              onValueChange={(v) => setContainerId(v)}
+              disabled={isLoadingContainers || containers.length === 0}
+            >
+              <SelectTrigger>
+                <SelectValue
+                  placeholder={
+                    isLoadingContainers
+                      ? t('Loading...')
+                      : containers.length === 0
+                        ? t('No containers')
+                        : t('Select')
+                  }
+                />
+              </SelectTrigger>
+              <SelectContent>
+                {containers.map((c) => {
+                  const id = c?.container_id
+                  if (typeof id !== 'string' || !id) return null
+                  const status =
+                    typeof c?.status === 'string' && c.status
+                      ? ` (${c.status})`
+                      : ''
+                  return (
+                    <SelectItem key={id} value={id}>
+                      {id}
+                      {status}
+                    </SelectItem>
+                  )
+                })}
+              </SelectContent>
+            </Select>
+          </div>
+          <div className='space-y-1'>
+            <div className='text-muted-foreground text-xs'>{t('Stream')}</div>
+            <Select
+              value={stream}
+              onValueChange={(v) => {
+                if (v === 'stderr' || v === 'all' || v === 'stdout') {
+                  setStream(v)
+                } else {
+                  setStream('stdout')
+                }
+              }}
+            >
+              <SelectTrigger>
+                <SelectValue placeholder={t('Select')} />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value='stdout'>stdout</SelectItem>
+                <SelectItem value='stderr'>stderr</SelectItem>
+                <SelectItem value='all'>all</SelectItem>
+              </SelectContent>
+            </Select>
+          </div>
+        </div>
+
+        <div
+          ref={scrollRef}
+          className='flex-1 overflow-auto rounded-md border bg-black p-4'
+          onScroll={(e) => {
+            const target = e.target as HTMLDivElement
+            const isAtBottom =
+              target.scrollHeight - target.scrollTop - target.clientHeight < 50
+            setAutoScroll(isAtBottom)
+          }}
+        >
+          {isLoadingContainers || isLoadingLogs ? (
+            <div className='flex items-center justify-center py-8'>
+              <Loader2 className='h-6 w-6 animate-spin text-gray-400' />
+            </div>
+          ) : containers.length === 0 ? (
+            <div className='py-8 text-center text-gray-400'>
+              {t('No containers')}
+            </div>
+          ) : !containerId ? (
+            <div className='py-8 text-center text-gray-400'>
+              {t('Please select a container')}
+            </div>
+          ) : !logsText.trim() ? (
+            <div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
+          ) : (
+            <div className='font-mono text-sm'>
+              {logLines.map((line, idx) => (
+                <div key={idx} className='whitespace-pre-wrap text-gray-200'>
+                  {line}
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 6 - 0
web/src/features/models/components/models-provider.tsx

@@ -1,6 +1,7 @@
 import React, { createContext, useContext, useState } from 'react'
 import type {
   Model,
+  ModelTabCategory,
   Vendor,
   SyncDiffData,
   SyncLocale,
@@ -42,6 +43,8 @@ type ModelsContextType = {
   setSyncWizardOptions: React.Dispatch<
     React.SetStateAction<{ locale: SyncLocale; source: SyncSource }>
   >
+  tabCategory: ModelTabCategory
+  setTabCategory: (category: ModelTabCategory) => void
 }
 
 // ============================================================================
@@ -73,6 +76,7 @@ export function ModelsProvider({ children }: { children: React.ReactNode }) {
     locale: 'zh',
     source: 'official',
   })
+  const [tabCategory, setTabCategory] = useState<ModelTabCategory>('metadata')
 
   return (
     <ModelsContext.Provider
@@ -91,6 +95,8 @@ export function ModelsProvider({ children }: { children: React.ReactNode }) {
         setUpstreamConflicts,
         syncWizardOptions,
         setSyncWizardOptions,
+        tabCategory,
+        setTabCategory,
       }}
     >
       {children}

+ 29 - 0
web/src/features/models/components/models-tabs.tsx

@@ -0,0 +1,29 @@
+import { useTranslation } from 'react-i18next'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import type { ModelTabCategory } from '../types'
+
+interface ModelsTabsProps {
+  value: ModelTabCategory
+  onValueChange: (value: ModelTabCategory) => void
+}
+
+export function ModelsTabs({ value, onValueChange }: ModelsTabsProps) {
+  const { t } = useTranslation()
+
+  const handleValueChange = (newValue: string) => {
+    onValueChange(newValue as ModelTabCategory)
+  }
+
+  return (
+    <Tabs value={value} onValueChange={handleValueChange} className='w-auto'>
+      <TabsList className='h-8'>
+        <TabsTrigger value='metadata' className='h-7 px-3'>
+          {t('Metadata')}
+        </TabsTrigger>
+        <TabsTrigger value='deployments' className='h-7 px-3'>
+          {t('Deployments')}
+        </TabsTrigger>
+      </TabsList>
+    </Tabs>
+  )
+}

+ 43 - 0
web/src/features/models/constants.ts

@@ -83,6 +83,49 @@ export function getSyncStatusOptions(t: TFunction) {
   ] as const
 }
 
+// ============================================================================
+// Deployment Status
+// ============================================================================
+
+export function getDeploymentStatusOptions(t: TFunction) {
+  return [
+    { label: t('All Status'), value: 'all' },
+    { label: t('Running'), value: 'running' },
+    { label: t('Completed'), value: 'completed' },
+    { label: t('Failed'), value: 'failed' },
+    { label: t('Deployment requested'), value: 'deployment requested' },
+    { label: t('Termination requested'), value: 'termination requested' },
+    { label: t('Destroyed'), value: 'destroyed' },
+  ] as const
+}
+
+export function getDeploymentStatusConfig(t: TFunction): Record<
+  string,
+  {
+    label: string
+    variant: 'success' | 'neutral' | 'warning' | 'danger'
+    showDot?: boolean
+  }
+> {
+  return {
+    running: { label: t('Running'), variant: 'success', showDot: true },
+    completed: { label: t('Completed'), variant: 'success' },
+    failed: { label: t('Failed'), variant: 'danger' },
+    error: { label: t('Failed'), variant: 'danger' },
+    destroyed: { label: t('Destroyed'), variant: 'danger' },
+    'deployment requested': {
+      label: t('Deployment requested'),
+      variant: 'warning',
+      showDot: true,
+    },
+    'termination requested': {
+      label: t('Termination requested'),
+      variant: 'warning',
+      showDot: true,
+    },
+  }
+}
+
 // ============================================================================
 // Quota Type
 // ============================================================================

+ 89 - 0
web/src/features/models/hooks/use-model-deployment-settings.ts

@@ -0,0 +1,89 @@
+import { useCallback, useEffect, useState } from 'react'
+import { getDeploymentSettings, testDeploymentConnection } from '../api'
+
+interface ConnectionState {
+  loading: boolean
+  ok: boolean | null
+  error: string | null
+}
+
+export function useModelDeploymentSettings() {
+  const [loading, setLoading] = useState(true)
+  const [settings, setSettings] = useState<Record<string, unknown>>({
+    'model_deployment.ionet.enabled': false,
+  })
+  const [connectionState, setConnectionState] = useState<ConnectionState>({
+    loading: false,
+    ok: null,
+    error: null,
+  })
+
+  const fetchSettings = useCallback(async () => {
+    setLoading(true)
+    try {
+      const response = await getDeploymentSettings()
+      if (response?.success) {
+        // Backend returns { enabled, configured, can_connect, ... }
+        setSettings({
+          'model_deployment.ionet.enabled': response?.data?.enabled === true,
+        })
+      }
+    } catch {
+      // Ignore errors, use default settings
+    } finally {
+      setLoading(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    fetchSettings()
+  }, [fetchSettings])
+
+  const isIoNetEnabled = Boolean(settings['model_deployment.ionet.enabled'])
+
+  const testConnection = useCallback(async () => {
+    setConnectionState({ loading: true, ok: null, error: null })
+    try {
+      const response = await testDeploymentConnection()
+      if (response?.success) {
+        setConnectionState({ loading: false, ok: true, error: null })
+        return
+      }
+      const message = response?.message || 'Connection failed'
+      setConnectionState({ loading: false, ok: false, error: message })
+    } catch (error: unknown) {
+      const errMsg =
+        error instanceof Error ? error.message : 'Connection failed'
+      setConnectionState({ loading: false, ok: false, error: errMsg })
+    }
+  }, [])
+
+  // Auto test connection when enabled
+  useEffect(() => {
+    if (!loading && isIoNetEnabled) {
+      testConnection()
+      return
+    }
+    setConnectionState({ loading: false, ok: null, error: null })
+  }, [loading, isIoNetEnabled, testConnection])
+
+  // Refresh on window focus (useful after saving settings in another page)
+  useEffect(() => {
+    const handler = () => {
+      fetchSettings()
+    }
+    window.addEventListener('focus', handler)
+    return () => window.removeEventListener('focus', handler)
+  }, [fetchSettings])
+
+  return {
+    loading,
+    settings,
+    isIoNetEnabled,
+    refresh: fetchSettings,
+    connectionLoading: connectionState.loading,
+    connectionOk: connectionState.ok,
+    connectionError: connectionState.error,
+    testConnection,
+  }
+}

+ 86 - 7
web/src/features/models/index.tsx

@@ -1,32 +1,111 @@
+import { useCallback, useEffect } from 'react'
+import { getRouteApi } from '@tanstack/react-router'
 import { useTranslation } from 'react-i18next'
 import { AppHeader, Main } from '@/components/layout'
+import { DeploymentAccessGuard } from './components/deployment-access-guard'
+import { DeploymentsTable } from './components/deployments-table'
 import { ModelsDialogs } from './components/models-dialogs'
 import { ModelsPrimaryButtons } from './components/models-primary-buttons'
-import { ModelsProvider } from './components/models-provider'
+import { ModelsProvider, useModels } from './components/models-provider'
 import { ModelsTable } from './components/models-table'
+import { ModelsTabs } from './components/models-tabs'
+import { useModelDeploymentSettings } from './hooks/use-model-deployment-settings'
 
-export function Models() {
+const route = getRouteApi('/_authenticated/models/')
+
+function ModelsContent() {
   const { t } = useTranslation()
+  const { tabCategory, setTabCategory } = useModels()
+  const navigate = route.useNavigate()
+  const search = route.useSearch()
+  const activeTab = (search.tab ?? 'metadata') as 'metadata' | 'deployments'
+
+  // keep context state in sync (for components that rely on it)
+  useEffect(() => {
+    if (tabCategory !== activeTab) {
+      setTabCategory(activeTab)
+    }
+  }, [activeTab, setTabCategory, tabCategory])
+
+  const setActiveTab = useCallback(
+    (tab: 'metadata' | 'deployments') => {
+      setTabCategory(tab)
+      navigate({
+        search: (prev) => ({
+          ...prev,
+          tab: tab === 'metadata' ? undefined : tab,
+        }),
+        replace: true,
+      })
+    },
+    [navigate, setTabCategory]
+  )
+
+  const {
+    loading: deploymentLoading,
+    isIoNetEnabled,
+    connectionLoading,
+    connectionOk,
+    connectionError,
+    testConnection,
+    refresh: refreshDeploymentSettings,
+  } = useModelDeploymentSettings()
+
+  // Ensure settings are fresh when switching to deployments tab
+  useEffect(() => {
+    if (activeTab === 'deployments') {
+      refreshDeploymentSettings()
+    }
+  }, [activeTab, refreshDeploymentSettings])
+
   return (
-    <ModelsProvider>
+    <>
       <AppHeader fixed />
 
       <Main>
         <div className='mb-2 flex flex-wrap items-center justify-between space-y-2 gap-x-4'>
           <div>
-            <h2 className='text-2xl font-bold tracking-tight'>{t('Models')}</h2>
+            <div className='flex flex-wrap items-center gap-x-3 gap-y-2'>
+              <h2 className='text-2xl font-bold tracking-tight'>
+                {t('Models')}
+              </h2>
+              <div className='w-full sm:w-auto'>
+                <ModelsTabs value={activeTab} onValueChange={setActiveTab} />
+              </div>
+            </div>
             <p className='text-muted-foreground'>
-              {t('Manage AI model metadata and vendor configurations')}
+              {t('Manage model metadata and deployments')}
             </p>
           </div>
-          <ModelsPrimaryButtons />
+          {activeTab === 'metadata' && <ModelsPrimaryButtons />}
         </div>
         <div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-y-0 lg:space-x-12'>
-          <ModelsTable />
+          {activeTab === 'metadata' ? (
+            <ModelsTable />
+          ) : (
+            <DeploymentAccessGuard
+              loading={deploymentLoading}
+              isEnabled={isIoNetEnabled}
+              connectionLoading={connectionLoading}
+              connectionOk={connectionOk}
+              connectionError={connectionError}
+              onRetry={testConnection}
+            >
+              <DeploymentsTable />
+            </DeploymentAccessGuard>
+          )}
         </div>
       </Main>
 
       <ModelsDialogs />
+    </>
+  )
+}
+
+export function Models() {
+  return (
+    <ModelsProvider>
+      <ModelsContent />
     </ModelsProvider>
   )
 }

+ 24 - 0
web/src/features/models/lib/deployments-utils.ts

@@ -0,0 +1,24 @@
+export function normalizeDeploymentStatus(status: unknown) {
+  return typeof status === 'string' ? status.trim().toLowerCase() : ''
+}
+
+export function formatRemainingMinutes(mins: unknown) {
+  const n =
+    typeof mins === 'string'
+      ? Number(mins)
+      : typeof mins === 'number'
+        ? mins
+        : NaN
+  if (!Number.isFinite(n)) return null
+
+  const total = Math.max(0, Math.round(n))
+  const days = Math.floor(total / 1440)
+  const hours = Math.floor((total % 1440) / 60)
+  const minutes = total % 60
+
+  const parts: string[] = []
+  if (days > 0) parts.push(`${days}d`)
+  if (hours > 0) parts.push(`${hours}h`)
+  if (parts.length === 0 || minutes > 0) parts.push(`${minutes}m`)
+  return parts.join(' ')
+}

+ 16 - 0
web/src/features/models/lib/query-keys.ts

@@ -30,3 +30,19 @@ export const prefillGroupsQueryKeys = {
   lists: () => [...prefillGroupsQueryKeys.all, 'list'] as const,
   list: (type?: string) => [...prefillGroupsQueryKeys.lists(), type] as const,
 }
+
+/**
+ * React Query cache keys for deployments
+ */
+export const deploymentsQueryKeys = {
+  all: ['deployments'] as const,
+  lists: () => [...deploymentsQueryKeys.all, 'list'] as const,
+  list: (filters: {
+    keyword?: string
+    status?: string
+    p?: number
+    page_size?: number
+  }) => [...deploymentsQueryKeys.lists(), filters] as const,
+  detail: (id: string | number) =>
+    [...deploymentsQueryKeys.all, 'detail', id] as const,
+}

+ 89 - 0
web/src/features/models/types.ts

@@ -278,3 +278,92 @@ export type SyncLocale = 'zh' | 'en' | 'ja'
  * Sync upstream source
  */
 export type SyncSource = 'official' | 'config'
+
+// ============================================================================
+// Model Deployments Types
+// ============================================================================
+
+/**
+ * Model tab type
+ */
+export type ModelTabCategory = 'metadata' | 'deployments'
+
+/**
+ * Deployment entity from API
+ */
+export interface Deployment {
+  id: string | number
+  container_name?: string
+  deployment_name?: string
+  name?: string
+  status?: string
+  provider?: string
+  /**
+   * Human readable string returned by backend, e.g. "2 hour 15 minutes"
+   * or "completed".
+   */
+  time_remaining?: string
+  /**
+   * Remaining minutes (numeric) returned by backend.
+   */
+  compute_minutes_remaining?: number
+  /**
+   * Served minutes (numeric) returned by backend.
+   */
+  compute_minutes_served?: number
+  /**
+   * Completed percent (0-100) returned by backend.
+   */
+  completed_percent?: number
+  hardware_info?: string | Record<string, unknown>
+  hardware_name?: string
+  brand_name?: string
+  hardware_quantity?: number
+  created_at?: string | number
+  updated_at?: string | number
+  [key: string]: unknown
+}
+
+/**
+ * Deployment settings response
+ */
+export interface DeploymentSettingsResponse {
+  success: boolean
+  message?: string
+  data?: {
+    enabled?: boolean
+    [key: string]: unknown
+  }
+}
+
+/**
+ * List deployments response
+ */
+export interface ListDeploymentsResponse {
+  success: boolean
+  message?: string
+  data?: {
+    items?: Deployment[]
+    total?: number
+    page?: number
+    page_size?: number
+    status_counts?: Record<string, number>
+  }
+}
+
+/**
+ * Deployment logs response
+ */
+export interface DeploymentLogsResponse {
+  success: boolean
+  message?: string
+  data?: {
+    logs?: Array<{
+      timestamp?: string
+      level?: string
+      message?: string
+      source?: string
+    }>
+    cursor?: string
+  }
+}

+ 29 - 0
web/src/features/profile/api.ts

@@ -5,6 +5,8 @@ import type {
   UpdateUserRequest,
   UpdateUserSettingsRequest,
   DeleteAccountRequest,
+  CheckinStatusResponse,
+  CheckinResponse,
 } from './types'
 
 // ============================================================================
@@ -94,3 +96,30 @@ export async function bindWeChat(code: string): Promise<ApiResponse> {
   const res = await api.get(`/api/oauth/wechat/bind?code=${code}`)
   return res.data
 }
+
+// ============================================================================
+// Checkin APIs
+// ============================================================================
+
+/**
+ * Get checkin status for a specific month
+ */
+export async function getCheckinStatus(
+  month: string
+): Promise<ApiResponse<CheckinStatusResponse>> {
+  const res = await api.get(`/api/user/checkin?month=${month}`)
+  return res.data
+}
+
+/**
+ * Perform daily checkin
+ */
+export async function performCheckin(
+  turnstileToken?: string
+): Promise<ApiResponse<CheckinResponse>> {
+  const url = turnstileToken
+    ? `/api/user/checkin?turnstile=${encodeURIComponent(turnstileToken)}`
+    : '/api/user/checkin'
+  const res = await api.post(url)
+  return res.data
+}

+ 470 - 0
web/src/features/profile/components/checkin-calendar-card.tsx

@@ -0,0 +1,470 @@
+import { useEffect, useState, useMemo, useCallback } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import {
+  CalendarDays,
+  ChevronDown,
+  ChevronLeft,
+  ChevronRight,
+  ChevronUp,
+  Sparkles,
+} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { formatQuotaWithCurrency } from '@/lib/currency'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+  TooltipProvider,
+} from '@/components/ui/tooltip'
+import { Turnstile } from '@/components/turnstile'
+import { getCheckinStatus, performCheckin } from '../api'
+import type { CheckinRecord } from '../types'
+
+interface CheckinCalendarCardProps {
+  checkinEnabled: boolean
+  turnstileEnabled: boolean
+  turnstileSiteKey: string
+}
+
+export function CheckinCalendarCard({
+  checkinEnabled,
+  turnstileEnabled,
+  turnstileSiteKey,
+}: CheckinCalendarCardProps) {
+  const { t } = useTranslation()
+  const [currentMonth, setCurrentMonth] = useState(() => {
+    const now = new Date()
+    return new Date(now.getFullYear(), now.getMonth(), 1)
+  })
+  const [checkinLoading, setCheckinLoading] = useState(false)
+  const [turnstileModalVisible, setTurnstileModalVisible] = useState(false)
+  const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0)
+  const [initialLoaded, setInitialLoaded] = useState(false)
+  const [collapsed, setCollapsed] = useState<boolean>(false)
+
+  const currentMonthStr = useMemo(() => {
+    const y = currentMonth.getFullYear()
+    const m = String(currentMonth.getMonth() + 1).padStart(2, '0')
+    return `${y}-${m}`
+  }, [currentMonth])
+
+  // Fetch checkin status
+  const {
+    data: checkinData,
+    isLoading,
+    refetch,
+  } = useQuery({
+    queryKey: ['checkin-status', currentMonthStr],
+    queryFn: async () => {
+      const res = await getCheckinStatus(currentMonthStr)
+      if (res.success && res.data) {
+        return res.data
+      }
+      throw new Error(res.message || t('Failed to fetch checkin status'))
+    },
+    enabled: checkinEnabled,
+    staleTime: 30000,
+  })
+
+  const checkinRecordsMap = useMemo(() => {
+    const map: Record<string, number> = {}
+    const records = checkinData?.stats?.records || []
+    records.forEach((record: CheckinRecord) => {
+      map[record.checkin_date] = record.quota_awarded
+    })
+    return map
+  }, [checkinData?.stats?.records])
+
+  const monthlyQuota = useMemo(() => {
+    const records = checkinData?.stats?.records || []
+    return records.reduce(
+      (sum: number, record: CheckinRecord) => sum + (record.quota_awarded || 0),
+      0
+    )
+  }, [checkinData?.stats?.records])
+
+  const todayString = useMemo(() => {
+    const d = new Date()
+    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+  }, [])
+
+  const checkedToday = checkinData?.stats?.checked_in_today === true
+  const todayAward = checkinRecordsMap[todayString]
+
+  useEffect(() => {
+    if (initialLoaded) return
+    if (isLoading) return
+    if (!checkinData) return
+    setCollapsed(checkedToday)
+    setInitialLoaded(true)
+  }, [checkinData, checkedToday, initialLoaded, isLoading])
+
+  const shouldTriggerTurnstile = useCallback(
+    (message?: string) => {
+      if (!turnstileEnabled) return false
+      if (typeof message !== 'string') return true
+      return message.includes('Turnstile')
+    },
+    [turnstileEnabled]
+  )
+
+  const doCheckin = useCallback(
+    async (token?: string) => {
+      setCheckinLoading(true)
+      try {
+        const res = await performCheckin(token)
+        if (res.success && res.data) {
+          toast.success(
+            `${t('Check-in successful! Received')} ${formatQuotaWithCurrency(res.data.quota_awarded)}`
+          )
+          refetch()
+          setTurnstileModalVisible(false)
+        } else {
+          if (!token && shouldTriggerTurnstile(res.message)) {
+            if (!turnstileSiteKey) {
+              toast.error('Turnstile is enabled but site key is empty.')
+              return
+            }
+            setTurnstileModalVisible(true)
+            return
+          }
+          if (token && shouldTriggerTurnstile(res.message)) {
+            setTurnstileWidgetKey((v) => v + 1)
+          }
+          toast.error(res.message || t('Check-in failed'))
+        }
+      } catch (error) {
+        toast.error(t('Check-in failed'))
+      } finally {
+        setCheckinLoading(false)
+      }
+    },
+    [refetch, shouldTriggerTurnstile, t, turnstileSiteKey]
+  )
+
+  const handlePrevMonth = () => {
+    setCurrentMonth(
+      new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)
+    )
+  }
+
+  const handleNextMonth = () => {
+    setCurrentMonth(
+      new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)
+    )
+  }
+
+  // Build calendar grid
+  const calendarDays = useMemo(() => {
+    const year = currentMonth.getFullYear()
+    const month = currentMonth.getMonth()
+    const firstDay = new Date(year, month, 1)
+    const lastDay = new Date(year, month + 1, 0)
+    const daysInMonth = lastDay.getDate()
+    const startDayOfWeek = firstDay.getDay() // 0 = Sunday
+
+    const days: Array<{ date: Date; isCurrentMonth: boolean }> = []
+
+    // Fill leading empty days
+    for (let i = 0; i < startDayOfWeek; i++) {
+      const d = new Date(year, month, -startDayOfWeek + i + 1)
+      days.push({ date: d, isCurrentMonth: false })
+    }
+
+    // Fill current month days
+    for (let i = 1; i <= daysInMonth; i++) {
+      days.push({ date: new Date(year, month, i), isCurrentMonth: true })
+    }
+
+    // Fill trailing empty days to complete the grid
+    const remaining = 7 - (days.length % 7)
+    if (remaining < 7) {
+      for (let i = 1; i <= remaining; i++) {
+        days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false })
+      }
+    }
+
+    return days
+  }, [currentMonth])
+
+  const weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
+
+  if (!checkinEnabled) {
+    return null
+  }
+
+  if (isLoading) {
+    return (
+      <div className='bg-card overflow-hidden rounded-2xl border'>
+        <div className='p-6'>
+          <div className='flex items-start justify-between gap-4'>
+            <div className='flex items-center gap-3'>
+              <Skeleton className='h-10 w-10 rounded-full' />
+              <div className='space-y-2'>
+                <Skeleton className='h-5 w-32' />
+                <Skeleton className='h-3 w-56' />
+              </div>
+            </div>
+            <Skeleton className='h-9 w-28 rounded-full' />
+          </div>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <TooltipProvider delayDuration={100}>
+      <Dialog
+        open={turnstileModalVisible}
+        onOpenChange={(open) => {
+          setTurnstileModalVisible(open)
+          if (!open) {
+            setTurnstileWidgetKey((v) => v + 1)
+          }
+        }}
+      >
+        <DialogContent className='sm:max-w-md'>
+          <DialogHeader>
+            <DialogTitle>{t('Security Check')}</DialogTitle>
+          </DialogHeader>
+          <div className='text-muted-foreground text-sm'>
+            {t('Please complete the security check to continue.')}
+          </div>
+          <div className='flex justify-center py-4'>
+            <Turnstile
+              key={turnstileWidgetKey}
+              siteKey={turnstileSiteKey}
+              onVerify={(token) => {
+                doCheckin(token)
+              }}
+              onExpire={() => {
+                setTurnstileWidgetKey((v) => v + 1)
+              }}
+            />
+          </div>
+        </DialogContent>
+      </Dialog>
+
+      <div className='bg-card overflow-hidden rounded-2xl border'>
+        {/* Header */}
+        <div className='border-b p-4 sm:p-6'>
+          <div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4'>
+            <button
+              type='button'
+              className='flex min-w-0 flex-1 items-start gap-3 text-left'
+              onClick={() => setCollapsed((v) => !v)}
+            >
+              <div className='bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-xl sm:h-11 sm:w-11'>
+                <CalendarDays
+                  className='h-4 w-4 sm:h-5 sm:w-5'
+                  strokeWidth={2}
+                />
+              </div>
+              <div className='min-w-0 flex-1'>
+                <div className='flex flex-wrap items-center gap-1.5 sm:gap-2'>
+                  <h3 className='text-base font-semibold tracking-tight sm:text-lg'>
+                    {t('Daily Check-in')}
+                  </h3>
+                  {checkedToday && (
+                    <div className='inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 sm:gap-1.5 sm:px-2.5 sm:text-xs dark:text-emerald-400'>
+                      <Sparkles className='h-2.5 w-2.5 sm:h-3 sm:w-3' />
+                      {t('Checked in')}
+                    </div>
+                  )}
+                  <span className='text-muted-foreground inline-flex items-center'>
+                    {collapsed ? (
+                      <ChevronDown className='h-4 w-4' />
+                    ) : (
+                      <ChevronUp className='h-4 w-4' />
+                    )}
+                  </span>
+                </div>
+                <p className='text-muted-foreground mt-1 line-clamp-2 text-xs sm:text-sm'>
+                  {checkedToday && todayAward !== undefined
+                    ? `${t('Today')} +${formatQuotaWithCurrency(todayAward)}`
+                    : t('Check in daily to receive random quota rewards')}
+                </p>
+              </div>
+            </button>
+            <Button
+              onClick={() => doCheckin()}
+              disabled={checkinLoading || checkedToday}
+              size='sm'
+              className='w-full shrink-0 rounded-full sm:w-auto'
+            >
+              {checkinLoading
+                ? t('Loading...')
+                : checkedToday
+                  ? t('Checked in')
+                  : t('Check in now')}
+            </Button>
+          </div>
+        </div>
+
+        {!collapsed ? (
+          <>
+            {/* Stats */}
+            <div className='grid grid-cols-3 gap-px border-b'>
+              <div className='bg-card p-3 text-center sm:p-5'>
+                <div className='text-xl font-semibold tracking-tight tabular-nums sm:text-2xl'>
+                  {checkinData?.stats?.total_checkins || 0}
+                </div>
+                <div className='text-muted-foreground mt-0.5 text-[10px] font-medium sm:mt-1 sm:text-xs'>
+                  {t('Total check-ins')}
+                </div>
+              </div>
+              <div className='bg-card p-3 text-center sm:p-5'>
+                <div className='text-xl font-semibold tracking-tight tabular-nums sm:text-2xl'>
+                  {formatQuotaWithCurrency(monthlyQuota, { digitsLarge: 0 })}
+                </div>
+                <div className='text-muted-foreground mt-0.5 text-[10px] font-medium sm:mt-1 sm:text-xs'>
+                  {t('This month')}
+                </div>
+              </div>
+              <div className='bg-card p-3 text-center sm:p-5'>
+                <div className='text-xl font-semibold tracking-tight tabular-nums sm:text-2xl'>
+                  {formatQuotaWithCurrency(
+                    checkinData?.stats?.total_quota || 0,
+                    {
+                      digitsLarge: 0,
+                    }
+                  )}
+                </div>
+                <div className='text-muted-foreground mt-0.5 text-[10px] font-medium sm:mt-1 sm:text-xs'>
+                  {t('Total earned')}
+                </div>
+              </div>
+            </div>
+
+            {/* Calendar */}
+            <div className='p-4 sm:p-6'>
+              <div className='space-y-3 sm:space-y-4'>
+                {/* Month navigation */}
+                <div className='flex items-center justify-between'>
+                  <h4 className='text-xs font-semibold sm:text-sm'>
+                    {currentMonth.toLocaleDateString('en-US', {
+                      year: 'numeric',
+                      month: 'long',
+                    })}
+                  </h4>
+                  <div className='flex items-center gap-0.5 sm:gap-1'>
+                    <Button
+                      variant='ghost'
+                      size='icon'
+                      className='h-7 w-7 sm:h-8 sm:w-8'
+                      onClick={handlePrevMonth}
+                    >
+                      <ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
+                    </Button>
+                    <Button
+                      variant='ghost'
+                      size='icon'
+                      className='h-7 w-7 sm:h-8 sm:w-8'
+                      onClick={handleNextMonth}
+                    >
+                      <ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
+                    </Button>
+                  </div>
+                </div>
+
+                {/* Calendar grid */}
+                <div className='grid grid-cols-7 gap-0.5 sm:gap-1'>
+                  {/* Week day headers */}
+                  {weekDays.map((day) => (
+                    <div
+                      key={day}
+                      className='text-muted-foreground flex h-7 items-center justify-center text-[10px] font-medium sm:h-8 sm:text-xs'
+                    >
+                      {day}
+                    </div>
+                  ))}
+
+                  {/* Calendar days */}
+                  {calendarDays.map((dayObj, idx) => {
+                    const dateStr = `${dayObj.date.getFullYear()}-${String(
+                      dayObj.date.getMonth() + 1
+                    ).padStart(2, '0')}-${String(
+                      dayObj.date.getDate()
+                    ).padStart(2, '0')}`
+                    const isToday = dateStr === todayString
+                    const quotaAwarded = checkinRecordsMap[dateStr]
+                    const isCheckedIn = quotaAwarded !== undefined
+                    const dayNum = dayObj.date.getDate()
+
+                    const dayButton = (
+                      <button
+                        key={idx}
+                        disabled={!dayObj.isCurrentMonth}
+                        className={cn(
+                          'relative flex h-9 w-full flex-col items-center justify-center rounded-lg text-xs font-medium transition-colors sm:h-10 sm:text-sm',
+                          dayObj.isCurrentMonth
+                            ? 'hover:bg-muted'
+                            : 'text-muted-foreground/40 cursor-default',
+                          isToday &&
+                            'bg-primary text-primary-foreground hover:bg-primary/90',
+                          !isToday && isCheckedIn && 'font-semibold'
+                        )}
+                      >
+                        <span className='tabular-nums'>{dayNum}</span>
+                        {isCheckedIn && !isToday && (
+                          <span className='absolute bottom-0.5 h-1 w-1 rounded-full bg-emerald-500 sm:bottom-1' />
+                        )}
+                      </button>
+                    )
+
+                    if (isCheckedIn && dayObj.isCurrentMonth) {
+                      return (
+                        <Tooltip key={idx}>
+                          <TooltipTrigger asChild>{dayButton}</TooltipTrigger>
+                          <TooltipContent>
+                            <div className='text-xs'>
+                              <div className='font-medium'>
+                                {t('Checked in')}
+                              </div>
+                              <div className='text-muted-foreground mt-0.5'>
+                                +{formatQuotaWithCurrency(quotaAwarded)}
+                              </div>
+                            </div>
+                          </TooltipContent>
+                        </Tooltip>
+                      )
+                    }
+
+                    return dayButton
+                  })}
+                </div>
+
+                {/* Footer hint */}
+                <div className='text-muted-foreground border-t pt-3 text-center text-[11px] sm:pt-4 sm:text-xs'>
+                  {t('You can only check in once per day')}
+                </div>
+
+                <div className='bg-muted/30 text-muted-foreground rounded-lg border p-3 text-xs'>
+                  <ul className='list-disc space-y-1 pl-5'>
+                    <li>
+                      {t('Check in daily to receive random quota rewards')}
+                    </li>
+                    <li>
+                      {t('Rewards will be added directly to your balance')}
+                    </li>
+                    <li>{t('Do not repeat check-in; only once per day')}</li>
+                  </ul>
+                </div>
+              </div>
+            </div>
+          </>
+        ) : null}
+      </div>
+    </TooltipProvider>
+  )
+}

+ 23 - 5
web/src/features/profile/index.tsx

@@ -1,4 +1,6 @@
+import { useStatus } from '@/hooks/use-status'
 import { AppHeader, Main } from '@/components/layout'
+import { CheckinCalendarCard } from './components/checkin-calendar-card'
 import { PasskeyCard } from './components/passkey-card'
 import { ProfileHeader } from './components/profile-header'
 import { ProfileSecurityCard } from './components/profile-security-card'
@@ -12,6 +14,13 @@ import { useProfile } from './hooks'
 
 export function Profile() {
   const { profile, loading, refreshProfile } = useProfile()
+  const { status } = useStatus()
+
+  const checkinEnabled = status?.checkin_enabled === true
+  const turnstileEnabled = !!(
+    status?.turnstile_check && status?.turnstile_site_key
+  )
+  const turnstileSiteKey = status?.turnstile_site_key || ''
 
   return (
     <>
@@ -31,11 +40,20 @@ export function Profile() {
             </div>
 
             {/* Right Column - Settings */}
-            <ProfileSettingsCard
-              profile={profile}
-              loading={loading}
-              onProfileUpdate={refreshProfile}
-            />
+            <div className='space-y-6'>
+              {checkinEnabled && (
+                <CheckinCalendarCard
+                  checkinEnabled={checkinEnabled}
+                  turnstileEnabled={turnstileEnabled}
+                  turnstileSiteKey={turnstileSiteKey}
+                />
+              )}
+              <ProfileSettingsCard
+                profile={profile}
+                loading={loading}
+                onProfileUpdate={refreshProfile}
+              />
+            </div>
           </div>
         </div>
       </Main>

+ 48 - 0
web/src/features/profile/types.ts

@@ -161,3 +161,51 @@ export interface TwoFASetupData {
   qr_code_data: string
   backup_codes: string[]
 }
+
+// ============================================================================
+// Checkin Type Definitions
+// ============================================================================
+
+/**
+ * Checkin record for a specific date
+ */
+export interface CheckinRecord {
+  /** Check-in date (YYYY-MM-DD) */
+  checkin_date: string
+  /** Quota awarded for this check-in */
+  quota_awarded: number
+}
+
+/**
+ * Checkin statistics
+ */
+export interface CheckinStats {
+  /** Whether user has checked in today */
+  checked_in_today: boolean
+  /** Total number of check-ins */
+  total_checkins: number
+  /** Total quota earned from check-ins */
+  total_quota: number
+  /** Current month check-in count */
+  checkin_count: number
+  /** Check-in records for the queried month */
+  records: CheckinRecord[]
+}
+
+/**
+ * Check-in status response
+ */
+export interface CheckinStatusResponse {
+  /** Whether check-in feature is enabled */
+  enabled: boolean
+  /** Check-in statistics */
+  stats: CheckinStats
+}
+
+/**
+ * Check-in action response
+ */
+export interface CheckinResponse {
+  /** Quota awarded for this check-in */
+  quota_awarded: number
+}

+ 187 - 0
web/src/features/system-settings/general/checkin-settings-section.tsx

@@ -0,0 +1,187 @@
+import { z } from 'zod'
+import { useForm, type Resolver } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Form,
+  FormControl,
+  FormDescription,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Switch } from '@/components/ui/switch'
+import { SettingsAccordion } from '../components/settings-accordion'
+import { useUpdateOption } from '../hooks/use-update-option'
+
+const schema = z.object({
+  enabled: z.boolean(),
+  minQuota: z.coerce.number().int().min(0),
+  maxQuota: z.coerce.number().int().min(0),
+})
+
+type Values = z.infer<typeof schema>
+
+export function CheckinSettingsSection({
+  defaultValues,
+}: {
+  defaultValues: {
+    enabled: boolean
+    minQuota: number
+    maxQuota: number
+  }
+}) {
+  const { t } = useTranslation()
+  const updateOption = useUpdateOption()
+
+  const form = useForm<Values>({
+    resolver: zodResolver(schema) as unknown as Resolver<Values>,
+    defaultValues: {
+      enabled: defaultValues.enabled,
+      minQuota: defaultValues.minQuota,
+      maxQuota: defaultValues.maxQuota,
+    },
+  })
+
+  const { isDirty, isSubmitting } = form.formState
+  const enabled = form.watch('enabled')
+
+  async function onSubmit(values: Values) {
+    const updates: Array<{ key: string; value: string }> = []
+
+    if (values.enabled !== defaultValues.enabled) {
+      updates.push({
+        key: 'checkin_setting.enabled',
+        value: String(values.enabled),
+      })
+    }
+
+    if (values.minQuota !== defaultValues.minQuota) {
+      updates.push({
+        key: 'checkin_setting.min_quota',
+        value: String(values.minQuota),
+      })
+    }
+
+    if (values.maxQuota !== defaultValues.maxQuota) {
+      updates.push({
+        key: 'checkin_setting.max_quota',
+        value: String(values.maxQuota),
+      })
+    }
+
+    if (updates.length === 0) {
+      toast.info(t('No changes to save'))
+      return
+    }
+
+    for (const update of updates) {
+      await updateOption.mutateAsync(update)
+    }
+
+    form.reset(values)
+  }
+
+  return (
+    <SettingsAccordion
+      value='checkin-settings'
+      title={t('Check-in Settings')}
+      description={t('Configure daily check-in rewards for users')}
+    >
+      <Form {...form}>
+        <form
+          onSubmit={form.handleSubmit(onSubmit)}
+          autoComplete='off'
+          className='space-y-6'
+        >
+          <FormField
+            control={form.control}
+            name='enabled'
+            render={({ field }) => (
+              <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
+                <div className='space-y-0.5'>
+                  <FormLabel className='text-base'>
+                    {t('Enable check-in feature')}
+                  </FormLabel>
+                  <FormDescription>
+                    {t(
+                      'Allow users to check in daily for random quota rewards'
+                    )}
+                  </FormDescription>
+                </div>
+                <FormControl>
+                  <Switch
+                    checked={field.value}
+                    onCheckedChange={field.onChange}
+                    disabled={updateOption.isPending || isSubmitting}
+                  />
+                </FormControl>
+              </FormItem>
+            )}
+          />
+
+          {enabled && (
+            <div className='grid gap-6 sm:grid-cols-2'>
+              <FormField
+                control={form.control}
+                name='minQuota'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Minimum check-in quota')}</FormLabel>
+                    <FormControl>
+                      <Input
+                        type='number'
+                        min={0}
+                        placeholder='1000'
+                        {...field}
+                      />
+                    </FormControl>
+                    <FormDescription>
+                      {t('Minimum quota amount awarded for check-in')}
+                    </FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='maxQuota'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Maximum check-in quota')}</FormLabel>
+                    <FormControl>
+                      <Input
+                        type='number'
+                        min={0}
+                        placeholder='10000'
+                        {...field}
+                      />
+                    </FormControl>
+                    <FormDescription>
+                      {t('Maximum quota amount awarded for check-in')}
+                    </FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </div>
+          )}
+
+          <Button
+            type='submit'
+            disabled={!isDirty || updateOption.isPending || isSubmitting}
+          >
+            {updateOption.isPending || isSubmitting
+              ? t('Saving...')
+              : t('Save check-in settings')}
+          </Button>
+        </form>
+      </Form>
+    </SettingsAccordion>
+  )
+}

+ 12 - 0
web/src/features/system-settings/general/index.tsx

@@ -4,6 +4,7 @@ import { Accordion } from '@/components/ui/accordion'
 import { useAccordionState } from '../hooks/use-accordion-state'
 import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
 import type { GeneralSettings } from '../types'
+import { CheckinSettingsSection } from './checkin-settings-section'
 import { PricingSection } from './pricing-section'
 import { QuotaSettingsSection } from './quota-settings-section'
 import { SystemBehaviorSection } from './system-behavior-section'
@@ -36,6 +37,9 @@ const defaultGeneralSettings: GeneralSettings = {
   DefaultCollapseSidebar: false,
   DemoSiteEnabled: false,
   SelfUseModeEnabled: false,
+  'checkin_setting.enabled': false,
+  'checkin_setting.min_quota': 1000,
+  'checkin_setting.max_quota': 10000,
 }
 
 export function GeneralSettings() {
@@ -115,6 +119,14 @@ export function GeneralSettings() {
             }}
           />
 
+          <CheckinSettingsSection
+            defaultValues={{
+              enabled: settings['checkin_setting.enabled'],
+              minQuota: settings['checkin_setting.min_quota'],
+              maxQuota: settings['checkin_setting.max_quota'],
+            }}
+          />
+
           <SystemBehaviorSection
             defaultValues={{
               RetryTimes: settings.RetryTimes,

+ 10 - 0
web/src/features/system-settings/integrations/index.tsx

@@ -4,6 +4,7 @@ import { useAccordionState } from '../hooks/use-accordion-state'
 import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
 import type { IntegrationSettings as IntegrationSettingsType } from '../types'
 import { EmailSettingsSection } from './email-settings-section'
+import { IoNetDeploymentSettingsSection } from './ionet-deployment-settings-section'
 import { MonitoringSettingsSection } from './monitoring-settings-section'
 import { PaymentSettingsSection } from './payment-settings-section'
 import { WorkerSettingsSection } from './worker-settings-section'
@@ -25,6 +26,8 @@ const defaultIntegrationSettings: IntegrationSettingsType = {
   AutomaticDisableKeywords: '',
   'monitor_setting.auto_test_channel_enabled': false,
   'monitor_setting.auto_test_channel_minutes': 10,
+  'model_deployment.ionet.api_key': '',
+  'model_deployment.ionet.enabled': false,
   PayAddress: '',
   EpayId: '',
   EpayKey: '',
@@ -114,6 +117,13 @@ export function IntegrationSettings() {
             }}
           />
 
+          <IoNetDeploymentSettingsSection
+            defaultValues={{
+              enabled: settings['model_deployment.ionet.enabled'],
+              apiKey: settings['model_deployment.ionet.api_key'],
+            }}
+          />
+
           <MonitoringSettingsSection
             defaultValues={{
               ChannelDisableThreshold: settings.ChannelDisableThreshold,

+ 252 - 0
web/src/features/system-settings/integrations/ionet-deployment-settings-section.tsx

@@ -0,0 +1,252 @@
+import { useState } from 'react'
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { CheckCircle2, Loader2, XCircle } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import {
+  Form,
+  FormControl,
+  FormDescription,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Switch } from '@/components/ui/switch'
+import { testDeploymentConnectionWithKey } from '@/features/models/api'
+import { SettingsAccordion } from '../components/settings-accordion'
+import { useUpdateOption } from '../hooks/use-update-option'
+
+const schema = z.object({
+  enabled: z.boolean(),
+  apiKey: z.string().optional(),
+})
+
+// NOTE: react-hook-form resolver uses the schema input type
+type Values = z.input<typeof schema>
+
+export function IoNetDeploymentSettingsSection({
+  defaultValues,
+}: {
+  defaultValues: {
+    enabled: boolean
+    apiKey: string
+  }
+}) {
+  const { t } = useTranslation()
+  const updateOption = useUpdateOption()
+
+  const form = useForm<Values>({
+    resolver: zodResolver(schema),
+    defaultValues: {
+      enabled: defaultValues.enabled,
+      apiKey: defaultValues.apiKey ?? '',
+    },
+  })
+
+  const { isDirty, isSubmitting } = form.formState
+  const enabled = form.watch('enabled')
+
+  const [testState, setTestState] = useState<{
+    loading: boolean
+    ok: boolean | null
+    error: string | null
+  }>({ loading: false, ok: null, error: null })
+
+  async function onSubmit(values: Values) {
+    const updates: Array<{ key: string; value: string }> = []
+
+    if (values.enabled !== defaultValues.enabled) {
+      updates.push({
+        key: 'model_deployment.ionet.enabled',
+        value: String(values.enabled),
+      })
+    }
+
+    if ((values.apiKey || '') !== (defaultValues.apiKey || '')) {
+      updates.push({
+        key: 'model_deployment.ionet.api_key',
+        value: String(values.apiKey || ''),
+      })
+    }
+
+    if (updates.length === 0) {
+      toast.info(t('No changes to save'))
+      return
+    }
+
+    for (const update of updates) {
+      await updateOption.mutateAsync(update)
+    }
+
+    form.reset(values)
+  }
+
+  const handleTestConnection = async () => {
+    setTestState({ loading: true, ok: null, error: null })
+    try {
+      const apiKey = form.getValues('apiKey')
+      const res = await testDeploymentConnectionWithKey(apiKey)
+      if (res?.success) {
+        setTestState({ loading: false, ok: true, error: null })
+        return
+      }
+      setTestState({
+        loading: false,
+        ok: false,
+        error: res?.message || t('Connection failed'),
+      })
+    } catch (err) {
+      setTestState({
+        loading: false,
+        ok: false,
+        error: err instanceof Error ? err.message : t('Connection failed'),
+      })
+    }
+  }
+
+  return (
+    <SettingsAccordion
+      value='ionet-deployments'
+      title={t('io.net Deployments')}
+      description={t('Configure io.net API key for model deployments')}
+    >
+      <Form {...form}>
+        <form
+          onSubmit={form.handleSubmit(onSubmit)}
+          autoComplete='off'
+          className='space-y-6'
+        >
+          <FormField
+            control={form.control}
+            name='enabled'
+            render={({ field }) => (
+              <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
+                <div className='space-y-0.5'>
+                  <FormLabel className='text-base'>
+                    {t('Enable io.net deployments')}
+                  </FormLabel>
+                  <FormDescription>
+                    {t('Enable io.net model deployment service in console')}
+                  </FormDescription>
+                </div>
+                <FormControl>
+                  <Switch
+                    checked={field.value}
+                    onCheckedChange={(v) => field.onChange(v)}
+                    disabled={updateOption.isPending || isSubmitting}
+                  />
+                </FormControl>
+              </FormItem>
+            )}
+          />
+
+          {enabled ? (
+            <>
+              <FormField
+                control={form.control}
+                name='apiKey'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('io.net API Key')}</FormLabel>
+                    <div className='flex gap-2'>
+                      <FormControl>
+                        <Input
+                          type='password'
+                          placeholder={t('Enter API Key')}
+                          autoComplete='off'
+                          {...field}
+                        />
+                      </FormControl>
+                      <Button
+                        type='button'
+                        variant='secondary'
+                        onClick={handleTestConnection}
+                        disabled={testState.loading || updateOption.isPending}
+                        className='shrink-0'
+                      >
+                        {testState.loading ? (
+                          <Loader2 className='me-2 size-4 animate-spin' />
+                        ) : null}
+                        {t('Test Connection')}
+                      </Button>
+                    </div>
+                    <FormDescription>
+                      {t('Used to authenticate with io.net deployment API')}
+                    </FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <Alert variant='default'>
+                <AlertTitle>{t('How to get an io.net API Key')}</AlertTitle>
+                <AlertDescription>
+                  <div className='space-y-2'>
+                    <ul className='list-disc space-y-1 pl-5'>
+                      <li>{t('Open the io.net console API Keys page')}</li>
+                      <li>
+                        {t(
+                          'Set Project to io.cloud when creating/selecting key'
+                        )}
+                      </li>
+                      <li>{t('Copy the key and paste it here')}</li>
+                    </ul>
+                    <Button
+                      type='button'
+                      variant='outline'
+                      onClick={() =>
+                        window.open('https://ai.io.net/ai/api-keys', '_blank')
+                      }
+                    >
+                      {t('Go to io.net API Keys')}
+                    </Button>
+                  </div>
+                </AlertDescription>
+              </Alert>
+
+              {testState.ok === true ? (
+                <Alert variant='default' className='flex items-center gap-2'>
+                  <CheckCircle2 className='size-4 text-green-600' />
+                  <div>
+                    <AlertTitle>{t('Connection successful')}</AlertTitle>
+                    <AlertDescription>
+                      {t('Connected to io.net service normally.')}
+                    </AlertDescription>
+                  </div>
+                </Alert>
+              ) : null}
+
+              {testState.ok === false && testState.error ? (
+                <Alert
+                  variant='destructive'
+                  className='flex items-center gap-2'
+                >
+                  <XCircle className='size-4' />
+                  <div>
+                    <AlertTitle>{t('Connection failed')}</AlertTitle>
+                    <AlertDescription>{testState.error}</AlertDescription>
+                  </div>
+                </Alert>
+              ) : null}
+            </>
+          ) : null}
+
+          <Button
+            type='submit'
+            disabled={!isDirty || updateOption.isPending || isSubmitting}
+          >
+            {updateOption.isPending || isSubmitting
+              ? t('Saving...')
+              : t('Save io.net settings')}
+          </Button>
+        </form>
+      </Form>
+    </SettingsAccordion>
+  )
+}

+ 5 - 0
web/src/features/system-settings/types.ts

@@ -54,6 +54,9 @@ export type GeneralSettings = {
   DefaultCollapseSidebar: boolean
   DemoSiteEnabled: boolean
   SelfUseModeEnabled: boolean
+  'checkin_setting.enabled': boolean
+  'checkin_setting.min_quota': number
+  'checkin_setting.max_quota': number
 }
 
 export type AuthSettings = {
@@ -138,6 +141,8 @@ export type IntegrationSettings = {
   AutomaticDisableKeywords: string
   'monitor_setting.auto_test_channel_enabled': boolean
   'monitor_setting.auto_test_channel_minutes': number
+  'model_deployment.ionet.api_key': string
+  'model_deployment.ionet.enabled': boolean
   PayAddress: string
   EpayId: string
   EpayKey: string

+ 119 - 1
web/src/i18n/locales/en.json

@@ -295,6 +295,23 @@
     "Change Password": "Change Password",
     "Change To": "Change To",
     "Change language": "Change language",
+    "Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
+    "Check in now": "Check in now",
+    "Check-in Rules": "Check-in Rules",
+    "Check-in Settings": "Check-in Settings",
+    "Check-in failed": "Check-in failed",
+    "Check-in successful! Received": "Check-in successful! Received",
+    "Checked in today": "Checked in today",
+    "Checked in today, total check-ins": "Checked in today, total check-ins",
+    "Checking connection...": "Checking connection...",
+    "Configure daily check-in rewards for users": "Configure daily check-in rewards for users",
+    "Enable check-in feature": "Enable check-in feature",
+    "Allow users to check in daily for random quota rewards": "Allow users to check in daily for random quota rewards",
+    "Minimum check-in quota": "Minimum check-in quota",
+    "Maximum check-in quota": "Maximum check-in quota",
+    "Minimum quota amount awarded for check-in": "Minimum quota amount awarded for check-in",
+    "Maximum quota amount awarded for check-in": "Maximum quota amount awarded for check-in",
+    "Save check-in settings": "Save check-in settings",
     "Channel": "Channel",
     "Channel Extra Settings": "Channel Extra Settings",
     "Channel ID": "Channel ID",
@@ -524,6 +541,7 @@
     "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.",
     "Dark": "Dark",
     "Dashboard": "Dashboard",
+    "Daily Check-in": "Daily Check-in",
     "Data Dashboard": "Data Dashboard",
     "Data directory:": "Data directory:",
     "Data is stored locally on this device. Use system backups to keep a safe copy.": "Data is stored locally on this device. Use system backups to keep a safe copy.",
@@ -531,6 +549,7 @@
     "Date and time when this announcement should be displayed": "Date and time when this announcement should be displayed",
     "DeepSeek": "DeepSeek",
     "Default": "Default",
+    "Deployments": "Deployments",
     "Default API Version *": "Default API Version *",
     "Default API version for this channel": "Default API version for this channel",
     "Default Collapse Sidebar": "Default Collapse Sidebar",
@@ -772,6 +791,7 @@
     "Failed": "Failed",
     "Failed to apply overwrite.": "Failed to apply overwrite.",
     "Failed to bind email": "Failed to bind email",
+    "Failed to fetch checkin status": "Failed to fetch check-in status",
     "Failed to change password": "Failed to change password",
     "Failed to clean logs": "Failed to clean logs",
     "Failed to complete Passkey login": "Failed to complete Passkey login",
@@ -1100,6 +1120,7 @@
     "Make it easier for teammates to pick the right group.": "Make it easier for teammates to pick the right group.",
     "Manage": "Manage",
     "Manage AI model metadata and vendor configurations": "Manage AI model metadata and vendor configurations",
+    "Manage model metadata and deployments": "Manage model metadata and deployments",
     "Manage API channels and provider configurations": "Manage API channels and provider configurations",
     "Manage Keys": "Manage Keys",
     "Manage Vendors": "Manage Vendors",
@@ -1125,6 +1146,7 @@
     "Max Requests (including failures)": "Max Requests (including failures)",
     "Max Success": "Max Success",
     "Max Successful Requests": "Max Successful Requests",
+    "Metadata": "Metadata",
     "Max requests per period": "Max requests per period",
     "Max successful requests": "Max successful requests",
     "Maximum 1000 characters. Supports Markdown and HTML.": "Maximum 1000 characters. Supports Markdown and HTML.",
@@ -1566,6 +1588,8 @@
     "Quota:": "Quota:",
     "Random": "Random",
     "Randomly select a key from the pool for each request": "Randomly select a key from the pool for each request",
+    "Received": "Received",
+    "Rewards will be directly added to your account balance": "Rewards will be directly added to your account balance",
     "Rate Limiting": "Rate Limiting",
     "Ratio": "Ratio",
     "Ratio Type": "Ratio Type",
@@ -1679,6 +1703,7 @@
     "Save Backup Codes": "Save Backup Codes",
     "Save Changes": "Save Changes",
     "Save Creem settings": "Save Creem settings",
+    "Save io.net settings": "Save io.net settings",
     "Save Models": "Save Models",
     "Save SSRF settings": "Save SSRF settings",
     "Save Settings": "Save Settings",
@@ -1712,6 +1737,8 @@
     "Secret Key": "Secret Key",
     "Secure & Reliable": "Secure & Reliable",
     "Security": "Security",
+    "Security Check": "Security Check",
+    "Please complete the security check to continue.": "Please complete the security check to continue.",
     "Select": "Select",
     "Select Language": "Select Language",
     "Select Model": "Select Model",
@@ -1939,6 +1966,7 @@
     "This model has both fixed price and ratio billing conflicts": "This model has both fixed price and ratio billing conflicts",
     "This model is not available in any group, or no group pricing information is configured.": "This model is not available in any group, or no group pricing information is configured.",
     "This page has not been created yet.": "This page has not been created yet.",
+    "This month": "This month",
     "This project must be used in compliance with the": "This project must be used in compliance with the",
     "This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.",
     "This will delete all": "This will delete all",
@@ -1973,6 +2001,8 @@
     "Topup Amount": "Topup Amount",
     "Total Count": "Total Count",
     "Total Earned": "Total Earned",
+    "Total check-ins": "Total check-ins",
+    "Total earned": "Total earned",
     "Total Quota": "Total Quota",
     "Total Tokens": "Total Tokens",
     "Total Usage": "Total Usage",
@@ -2169,6 +2199,7 @@
     "You are about to delete {{count}} API key(s).": "You are about to delete {{count}} API key(s).",
     "You can close this tab once the binding completes or a success message appears in the original window.": "You can close this tab once the binding completes or a success message appears in the original window.",
     "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.",
+    "You can only check in once per day": "You can only check in once per day",
     "You don't have necessary permission": "You don't have necessary permission",
     "You have unsaved changes": "You have unsaved changes",
     "You have unsaved changes. Are you sure you want to leave?": "You have unsaved changes. Are you sure you want to leave?",
@@ -2335,6 +2366,93 @@
     "xAI": "xAI",
     "{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
     "| Based on": "| Based on",
-    "© 2025 Your Company. All rights reserved.": "© 2025 Your Company. All rights reserved."
+    "© 2025 Your Company. All rights reserved.": "© 2025 Your Company. All rights reserved.",
+    "Model deployment service is disabled": "Model deployment service is disabled",
+    "Configuration required": "Configuration required",
+    "Please enable io.net model deployment service and configure an API key in System Settings.": "Please enable io.net model deployment service and configure an API key in System Settings.",
+    "Go to settings": "Go to settings",
+    "Connection failed": "Connection failed",
+    "Connection error": "Connection error",
+    "Deleted successfully": "Deleted successfully",
+    "Delete failed": "Delete failed",
+    "Time remaining": "Time remaining",
+    "Search deployments...": "Search deployments...",
+    "Create deployment": "Create deployment",
+    "No data": "No data",
+    "Total {{total}}": "Total {{total}}",
+    "Confirm delete": "Confirm delete",
+    "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.",
+    "Deployment logs": "Deployment logs",
+    "Deployment ID": "Deployment ID",
+    "Download": "Download",
+    "No logs": "No logs",
+    "Deployment created successfully": "Deployment created successfully",
+    "Failed to create deployment": "Failed to create deployment",
+    "Container name": "Container name",
+    "Hardware type": "Hardware type",
+    "Deployment location": "Deployment location",
+    "Select locations": "Select locations",
+    "GPU count": "GPU count",
+    "Replica count": "Replica count",
+    "Duration (hours)": "Duration (hours)",
+    "Price estimation": "Price estimation",
+    "Calculating...": "Calculating...",
+    "Not calculated": "Not calculated",
+    "Advanced configuration": "Advanced configuration",
+    "Environment variables (JSON)": "Environment variables (JSON)",
+    "Secret environment variables (JSON)": "Secret environment variables (JSON)",
+    "Submitting...": "Submitting...",
+    "io.net Deployments": "io.net Deployments",
+    "Configure io.net API key for model deployments": "Configure io.net API key for model deployments",
+    "Enable io.net deployments": "Enable io.net deployments",
+    "Enable io.net model deployment service in console": "Enable io.net model deployment service in console",
+    "Enter API Key": "Enter API Key",
+    "Used to authenticate with io.net deployment API": "Used to authenticate with io.net deployment API",
+    "Connection successful": "Connection successful",
+    "Connected to io.net service normally.": "Connected to io.net service normally.",
+    "Ollama Models": "Ollama Models",
+    "Manage Ollama Models": "Manage Ollama Models",
+    "Manage local models for:": "Manage local models for:",
+    "This channel is not an Ollama channel.": "This channel is not an Ollama channel.",
+    "Pull model": "Pull model",
+    "Pull": "Pull",
+    "Pulling...": "Pulling...",
+    "Status:": "Status:",
+    "Local models": "Local models",
+    "Select models and apply to channel models list.": "Select models and apply to channel models list.",
+    "Select all (filtered)": "Select all (filtered)",
+    "Append to channel": "Append to channel",
+    "Replace channel models": "Replace channel models",
+    "No models found.": "No models found.",
+    "Models appended successfully": "Models appended successfully",
+    "Please enter model name": "Please enter model name",
+    "Please set Ollama API Base URL first": "Please set Ollama API Base URL first",
+    "Model pull failed: {{msg}}": "Model pull failed: {{msg}}",
+    "Model deleted": "Model deleted",
+    "AWS Key Format": "AWS Key Format",
+    "Select key format": "Select key format",
+    "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey",
+    "API Key mode: use APIKey|Region": "API Key mode: use APIKey|Region",
+    "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK mode: use AccessKey|SecretAccessKey|Region",
+    "Field passthrough controls": "Field passthrough controls",
+    "These toggles affect whether certain request fields are passed through to the upstream provider.": "These toggles affect whether certain request fields are passed through to the upstream provider.",
+    "Allow service_tier passthrough": "Allow service_tier passthrough",
+    "Pass through the service_tier field": "Pass through the service_tier field",
+    "Disable store passthrough": "Disable store passthrough",
+    "When enabled, the store field will be blocked": "When enabled, the store field will be blocked",
+    "Allow safety_identifier passthrough": "Allow safety_identifier passthrough",
+    "Pass through the safety_identifier field": "Pass through the safety_identifier field",
+    "From IO.NET deployment": "From IO.NET deployment",
+    "Click to open deployment": "Click to open deployment",
+    "Enter API Key, one per line, format: APIKey|Region": "Enter API Key, one per line, format: APIKey|Region",
+    "Enter API Key, format: APIKey|Region": "Enter API Key, format: APIKey|Region",
+    "Enter key, one per line, format: AccessKey|SecretAccessKey|Region": "Enter key, one per line, format: AccessKey|SecretAccessKey|Region",
+    "Enter key, format: AccessKey|SecretAccessKey|Region": "Enter key, format: AccessKey|SecretAccessKey|Region",
+    "Service account JSON file(s)": "Service account JSON file(s)",
+    "Please upload key file(s)": "Please upload key file(s)",
+    "Failed to parse JSON file: {{name}}": "Failed to parse JSON file: {{name}}",
+    "Parsed {{count}} service account file(s)": "Parsed {{count}} service account file(s)",
+    "Upload multiple JSON files in batch modes": "Upload multiple JSON files in batch modes",
+    "Upload a single service account JSON file": "Upload a single service account JSON file"
   }
 }

+ 120 - 2
web/src/i18n/locales/fr.json

@@ -295,6 +295,23 @@
     "Change Password": "Changer le mot de passe",
     "Change To": "Changer en",
     "Change language": "Changer de langue",
+    "Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires",
+    "Check in now": "Se connecter maintenant",
+    "Check-in Rules": "Règles de connexion",
+    "Check-in Settings": "Paramètres de connexion",
+    "Check-in failed": "Échec de la connexion",
+    "Check-in successful! Received": "Connexion réussie ! Reçu",
+    "Checked in today": "Connecté aujourd'hui",
+    "Checked in today, total check-ins": "Connecté aujourd'hui, total des connexions",
+    "Checking connection...": "Vérification de la connexion...",
+    "Configure daily check-in rewards for users": "Configurer les récompenses de connexion quotidienne pour les utilisateurs",
+    "Enable check-in feature": "Activer la fonction de connexion",
+    "Allow users to check in daily for random quota rewards": "Permettre aux utilisateurs de se connecter quotidiennement pour des récompenses de quota aléatoires",
+    "Minimum check-in quota": "Quota minimum de connexion",
+    "Maximum check-in quota": "Quota maximum de connexion",
+    "Minimum quota amount awarded for check-in": "Montant minimum de quota attribué pour la connexion",
+    "Maximum quota amount awarded for check-in": "Montant maximum de quota attribué pour la connexion",
+    "Save check-in settings": "Enregistrer les paramètres de connexion",
     "Channel": "Canal",
     "Channel Extra Settings": "Paramètres supplémentaires du canal",
     "Channel ID": "ID du Canal",
@@ -524,6 +541,7 @@
     "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Multiplicateurs personnalisés lorsque des groupes d'utilisateurs spécifiques utilisent des groupes de jetons spécifiques. Exemple : les utilisateurs VIP obtiennent un taux de 0,9x lorsqu'ils utilisent les jetons du groupe \"edit_this\".",
     "Dark": "Sombre",
     "Dashboard": "Tableau de bord",
+    "Daily Check-in": "Daily Check-in",
     "Data Dashboard": "Tableau de bord des données",
     "Data directory:": "Répertoire des données :",
     "Data is stored locally on this device. Use system backups to keep a safe copy.": "Les données sont stockées localement sur cet appareil. Utilisez les sauvegardes système pour conserver une copie sécurisée.",
@@ -531,6 +549,7 @@
     "Date and time when this announcement should be displayed": "Date et heure auxquelles cette annonce doit être affichée",
     "DeepSeek": "DeepSeek",
     "Default": "Par défaut",
+    "Deployments": "Déploiements",
     "Default API Version *": "Version API par défaut *",
     "Default API version for this channel": "Version API par défaut pour ce canal",
     "Default Collapse Sidebar": "Réduire la barre latérale par défaut",
@@ -771,7 +790,8 @@
     "Fail Reason Details": "Détails de la raison de l'échec",
     "Failed": "Échec",
     "Failed to apply overwrite.": "Échec de l'application de l'écrasement.",
-    "Failed to bind email": "Échec de la liaison de l’e-mail",
+    "Failed to bind email": "Échec de la liaison de l'e-mail",
+    "Failed to fetch checkin status": "Échec de la récupération du statut d'enregistrement",
     "Failed to change password": "Échec du changement de mot de passe",
     "Failed to clean logs": "Échec du nettoyage des journaux",
     "Failed to complete Passkey login": "Impossible de terminer la connexion Passkey",
@@ -1100,6 +1120,7 @@
     "Make it easier for teammates to pick the right group.": "Faciliter le choix du bon groupe pour les coéquipiers.",
     "Manage": "Gérer",
     "Manage AI model metadata and vendor configurations": "Gérer les métadonnées des modèles d'IA et les configurations des fournisseurs",
+    "Manage model metadata and deployments": "Gérer les métadonnées des modèles et les déploiements",
     "Manage API channels and provider configurations": "Gérer les canaux d'API et les configurations des fournisseurs",
     "Manage Keys": "Gérer les clés",
     "Manage Vendors": "Gérer les fournisseurs",
@@ -1125,6 +1146,7 @@
     "Max Requests (including failures)": "Max Requêtes (incluant les échecs)",
     "Max Success": "Max Succès",
     "Max Successful Requests": "Max Requêtes réussies",
+    "Metadata": "Métadonnées",
     "Max requests per period": "Nombre max de requêtes par période",
     "Max successful requests": "Nombre max de requêtes réussies",
     "Maximum 1000 characters. Supports Markdown and HTML.": "Maximum 1000 caractères. Prend en charge Markdown et HTML.",
@@ -1566,6 +1588,8 @@
     "Quota:": "Quota :",
     "Random": "Aléatoire",
     "Randomly select a key from the pool for each request": "Sélectionner aléatoirement une clé du pool pour chaque requête",
+    "Received": "Received",
+    "Rewards will be directly added to your account balance": "Les récompenses seront directement ajoutées au solde de votre compte",
     "Rate Limiting": "Limitation du débit",
     "Ratio": "Ratio",
     "Ratio Type": "Type de ratio",
@@ -1679,6 +1703,7 @@
     "Save Backup Codes": "Sauvegarder les codes de secours",
     "Save Changes": "Enregistrer les modifications",
     "Save Creem settings": "Enregistrer les paramètres Creem",
+    "Save io.net settings": "Enregistrer les paramètres io.net",
     "Save Models": "Enregistrer les modèles",
     "Save SSRF settings": "Enregistrer les paramètres SSRF",
     "Save Settings": "Enregistrer les paramètres",
@@ -1712,6 +1737,8 @@
     "Secret Key": "Clé secrète",
     "Secure & Reliable": "Sécurisé et fiable",
     "Security": "Sécurité",
+    "Security Check": "Vérification de sécurité",
+    "Please complete the security check to continue.": "Veuillez compléter la vérification de sécurité pour continuer.",
     "Select": "Sélectionner",
     "Select Language": "Sélectionner la langue",
     "Select Model": "Sélectionner le modèle",
@@ -1939,6 +1966,7 @@
     "This model has both fixed price and ratio billing conflicts": "Ce modèle présente des conflits de facturation à la fois en prix fixe et au ratio",
     "This model is not available in any group, or no group pricing information is configured.": "Ce modèle n'est disponible dans aucun groupe, ou aucune information de tarification de groupe n'est configurée.",
     "This page has not been created yet.": "Cette page n'a pas encore été créée.",
+    "This month": "This month",
     "This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
     "This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.",
     "This will delete all": "Cela supprimera tout",
@@ -1973,6 +2001,8 @@
     "Topup Amount": "Montant de la recharge",
     "Total Count": "Nombre total",
     "Total Earned": "Total gagné",
+    "Total check-ins": "Total check-ins",
+    "Total earned": "Total earned",
     "Total Quota": "Quota total",
     "Total Tokens": "Jetons totaux",
     "Total Usage": "Utilisation totale",
@@ -2169,6 +2199,7 @@
     "You are about to delete {{count}} API key(s).": "Vous êtes sur le point de supprimer {{count}} clé(s) API.",
     "You can close this tab once the binding completes or a success message appears in the original window.": "Vous pouvez fermer cet onglet une fois la liaison terminée ou qu'un message de succès apparaît dans la fenêtre d'origine.",
     "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Vous pouvez les ajouter manuellement dans \"Noms de modèles personnalisés\", cliquer sur \"Remplir\" puis soumettre, ou utiliser les opérations ci-dessous pour les gérer automatiquement.",
+    "You can only check in once per day": "You can only check in once per day",
     "You don't have necessary permission": "Vous n'avez pas la permission nécessaire",
     "You have unsaved changes": "Vous avez des modifications non enregistrées",
     "You have unsaved changes. Are you sure you want to leave?": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter ?",
@@ -2335,6 +2366,93 @@
     "xAI": "xAI",
     "{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
     "| Based on": "| Basé sur",
-    "© 2025 Your Company. All rights reserved.": "© 2025 Votre entreprise. Tous droits réservés."
+    "© 2025 Your Company. All rights reserved.": "© 2025 Votre entreprise. Tous droits réservés.",
+    "Model deployment service is disabled": "Le service de déploiement de modèles est désactivé",
+    "Configuration required": "Configuration requise",
+    "Please enable io.net model deployment service and configure an API key in System Settings.": "Veuillez activer le service de déploiement de modèles io.net et configurer une clé API dans les Paramètres système.",
+    "Go to settings": "Aller aux paramètres",
+    "Connection failed": "Connexion échouée",
+    "Connection error": "Erreur de connexion",
+    "Deleted successfully": "Supprimé avec succès",
+    "Delete failed": "Échec de la suppression",
+    "Time remaining": "Temps restant",
+    "Search deployments...": "Rechercher des déploiements...",
+    "Create deployment": "Créer un déploiement",
+    "No data": "Aucune donnée",
+    "Total {{total}}": "Total {{total}}",
+    "Confirm delete": "Confirmer la suppression",
+    "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer le déploiement \"{{name}}\" ? Cette action est irréversible.",
+    "Deployment logs": "Journaux de déploiement",
+    "Deployment ID": "ID du déploiement",
+    "Download": "Télécharger",
+    "No logs": "Aucun journal",
+    "Deployment created successfully": "Déploiement créé avec succès",
+    "Failed to create deployment": "Échec de la création du déploiement",
+    "Container name": "Nom du conteneur",
+    "Hardware type": "Type de matériel",
+    "Deployment location": "Emplacement du déploiement",
+    "Select locations": "Sélectionner des emplacements",
+    "GPU count": "Nombre de GPU",
+    "Replica count": "Nombre de réplicas",
+    "Duration (hours)": "Durée (heures)",
+    "Price estimation": "Estimation du prix",
+    "Calculating...": "Calcul en cours...",
+    "Not calculated": "Non calculé",
+    "Advanced configuration": "Configuration avancée",
+    "Environment variables (JSON)": "Variables d'environnement (JSON)",
+    "Secret environment variables (JSON)": "Variables d'environnement secrètes (JSON)",
+    "Submitting...": "Envoi...",
+    "io.net Deployments": "Déploiements io.net",
+    "Configure io.net API key for model deployments": "Configurer la clé API io.net pour les déploiements de modèles",
+    "Enable io.net deployments": "Activer les déploiements io.net",
+    "Enable io.net model deployment service in console": "Activer le service de déploiement de modèles io.net dans la console",
+    "Enter API Key": "Saisir la clé API",
+    "Used to authenticate with io.net deployment API": "Utilisée pour l'authentification auprès de l'API de déploiement io.net",
+    "Connection successful": "Connexion réussie",
+    "Connected to io.net service normally.": "Connexion au service io.net réussie.",
+    "Ollama Models": "Ollama Models",
+    "Manage Ollama Models": "Manage Ollama Models",
+    "Manage local models for:": "Manage local models for:",
+    "This channel is not an Ollama channel.": "This channel is not an Ollama channel.",
+    "Pull model": "Pull model",
+    "Pull": "Pull",
+    "Pulling...": "Pulling...",
+    "Status:": "Statut :",
+    "Local models": "Modèles locaux",
+    "Select models and apply to channel models list.": "Sélectionnez les modèles et appliquez-les à la liste des modèles de canaux.",
+    "Select all (filtered)": "Tout sélectionner (filtré)",
+    "Append to channel": "Ajouter au canal",
+    "Replace channel models": "Remplacer les modèles du canal",
+    "No models found.": "Aucun modèle trouvé.",
+    "Models appended successfully": "Modèles ajoutés avec succès",
+    "Please enter model name": "Veuillez entrer le nom du modèle",
+    "Please set Ollama API Base URL first": "Veuillez d'abord définir l'URL de base de l'API Ollama",
+    "Model pull failed: {{msg}}": "Échec du téléchargement du modèle : {{msg}}",
+    "Model deleted": "Modèle supprimé",
+    "AWS Key Format": "Format de clé AWS",
+    "Select key format": "Sélectionner le format de clé",
+    "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey",
+    "API Key mode: use APIKey|Region": "Mode clé API : utiliser APIKey|Region",
+    "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region",
+    "Field passthrough controls": "Contrôles de transmission des champs",
+    "These toggles affect whether certain request fields are passed through to the upstream provider.": "Ces bascules déterminent si certains champs de demande sont transmis au fournisseur en amont.",
+    "Allow service_tier passthrough": "Autoriser la transmission de service_tier",
+    "Pass through the service_tier field": "Transmettre le champ service_tier",
+    "Disable store passthrough": "Désactiver la transmission du champ store",
+    "When enabled, the store field will be blocked": "Lorsqu'il est activé, le champ de la boutique sera bloqué",
+    "Allow safety_identifier passthrough": "Autoriser la transmission de safety_identifier",
+    "Pass through the safety_identifier field": "Transmettre le champ safety_identifier",
+    "From IO.NET deployment": "Depuis le déploiement IO.NET",
+    "Click to open deployment": "Cliquez pour ouvrir le déploiement",
+    "Enter API Key, one per line, format: APIKey|Region": "Enter API Key, one per line, format: APIKey|Region",
+    "Enter API Key, format: APIKey|Region": "Enter API Key, format: APIKey|Region",
+    "Enter key, one per line, format: AccessKey|SecretAccessKey|Region": "Enter key, one per line, format: AccessKey|SecretAccessKey|Region",
+    "Enter key, format: AccessKey|SecretAccessKey|Region": "Enter key, format: AccessKey|SecretAccessKey|Region",
+    "Service account JSON file(s)": "Fichier(s) JSON de compte de service",
+    "Please upload key file(s)": "Veuillez télécharger le (s) fichier(s) clé (s)",
+    "Failed to parse JSON file: {{name}}": "Échec de l'analyse du fichier JSON : {{name}}",
+    "Parsed {{count}} service account file(s)": "{{count}} fichier(s) de compte de service analysé(s)",
+    "Upload multiple JSON files in batch modes": "Télécharger plusieurs fichiers JSON en mode batch",
+    "Upload a single service account JSON file": "Télécharger un seul fichier JSON de compte de service"
   }
 }

+ 139 - 21
web/src/i18n/locales/ja.json

@@ -1,7 +1,7 @@
 {
   "translation": {
     "360": "360",
-    "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"",
+    "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default \":\" us - central 1 \", \"claude -3 -5 - sonnet -20240620 \":\" europe - west 1 \"",
     "% off": "% OFF",
     "(Leave empty to dissolve tag)": "(タグを解除するには空欄のままにしてください)",
     "(Optional: redirect model names)": "(オプション: モデル名をリダイレクト)",
@@ -37,8 +37,8 @@
     "@lobehub/icons key name": "@lobehub/icons キー名",
     "A powerful API proxy service supporting OpenAI, Claude, Gemini and other mainstream AI models, helping you easily manage and call various API services": "OpenAI、Claude、Gemini、その他主要なAIモデルをサポートする強力なAPIプロキシサービスで、様々なAPIサービスを簡単に管理・呼び出しできます",
     "AGPL v3.0 License": "AGPL v3.0ライセンス",
-    "AI Proxy": "AI Proxy",
-    "AI Proxy Library": "AI Proxy Library",
+    "AI Proxy": "AIプロキシ",
+    "AI Proxy Library": "AIプロキシライブラリ",
     "AI models": "AIモデル",
     "AI models supported": "AIモデル対応",
     "AIGC2D": "AIGC2D",
@@ -231,7 +231,7 @@
     "Backup codes regenerated successfully": "バックアップコードが正常に再生成されました",
     "Badge Color": "バッジの色",
     "Baidu": "Baidu",
-    "Baidu V2": "Baidu V2",
+    "Baidu V2": "Baidu V 2",
     "Balance": "残高",
     "Balance queried successfully": "残高の取得に成功しました",
     "Balance updated successfully": "残高が正常に更新されました",
@@ -295,6 +295,23 @@
     "Change Password": "パスワードの変更",
     "Change To": "変更先",
     "Change language": "言語を変更",
+    "Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
+    "Check in now": "今すぐチェックイン",
+    "Check-in Rules": "チェックインルール",
+    "Check-in Settings": "チェックイン設定",
+    "Check-in failed": "チェックインできませんでした",
+    "Check-in successful! Received": "チェックインに成功しました!受け取りました",
+    "Checked in today": "本日チェックイン済み",
+    "Checked in today, total check-ins": "本日チェックイン、合計チェックイン回数",
+    "Checking connection...": "接続を確認しています...",
+    "Configure daily check-in rewards for users": "ユーザーの毎日のチェックイン報酬を設定する",
+    "Enable check-in feature": "チェックイン機能を有効にする",
+    "Allow users to check in daily for random quota rewards": "ユーザーが毎日チェックインしてランダムなクォータ報酬を受け取れるようにする",
+    "Minimum check-in quota": "最小チェックインクォータ",
+    "Maximum check-in quota": "最大チェックインクォータ",
+    "Minimum quota amount awarded for check-in": "チェックインで付与される最小クォータ量",
+    "Maximum quota amount awarded for check-in": "チェックインで付与される最大クォータ量",
+    "Save check-in settings": "チェックイン設定を保存",
     "Channel": "チャネル",
     "Channel Extra Settings": "チャネル詳細設定",
     "Channel ID": "チャネルID",
@@ -355,8 +372,8 @@
     "Click to view full details": "クリックして詳細全体を表示",
     "Click to view full error message": "クリックしてエラーメッセージ全体を表示",
     "Click to view full prompt": "クリックしてプロンプト全体を表示",
-    "Client ID": "Client ID",
-    "Client Secret": "Client Secret",
+    "Client ID": "クライアントID",
+    "Client Secret": "クライアントシークレット",
     "Close": "閉じる",
     "Close Today": "今日は表示しない",
     "Close dialog": "ダイアログを閉じる",
@@ -524,6 +541,7 @@
     "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "特定のユーザーグループが特定のトークングループを使用する場合のカスタム乗数。例: VIPユーザーが「edit_this」グループトークンを使用する場合、0.9倍のレートが適用されます。",
     "Dark": "ダーク",
     "Dashboard": "ダッシュボード",
+    "Daily Check-in": "毎日のチェックイン",
     "Data Dashboard": "データダッシュボード",
     "Data directory:": "データディレクトリ:",
     "Data is stored locally on this device. Use system backups to keep a safe copy.": "データはこのデバイスにローカルに保存されます。安全なコピーを保持するためにシステムバックアップを使用してください。",
@@ -531,6 +549,7 @@
     "Date and time when this announcement should be displayed": "このアナウンスが表示されるべき日時",
     "DeepSeek": "DeepSeek",
     "Default": "デフォルト",
+    "Deployments": "デプロイ",
     "Default API Version *": "デフォルトのAPIバージョン *",
     "Default API version for this channel": "このチャンネルのデフォルトのAPIバージョン",
     "Default Collapse Sidebar": "デフォルトのサイドバー折りたたみ",
@@ -772,6 +791,7 @@
     "Failed": "失敗",
     "Failed to apply overwrite.": "オーバーライトの適用に失敗しました。",
     "Failed to bind email": "メールのバインドに失敗しました",
+    "Failed to fetch checkin status": "チェックインステータスの取得に失敗しました",
     "Failed to change password": "パスワードの変更に失敗しました",
     "Failed to clean logs": "ログのクリーンアップに失敗しました",
     "Failed to complete Passkey login": "Passkeyログインの完了に失敗しました",
@@ -1100,6 +1120,7 @@
     "Make it easier for teammates to pick the right group.": "チームメイトが適切なグループを選択しやすくする。",
     "Manage": "管理",
     "Manage AI model metadata and vendor configurations": "AIモデルのメタデータとベンダー構成を管理する",
+    "Manage model metadata and deployments": "モデルのメタデータとデプロイを管理する",
     "Manage API channels and provider configurations": "APIチャネルとプロバイダー構成を管理する",
     "Manage Keys": "キーの管理",
     "Manage Vendors": "ベンダーの管理",
@@ -1125,6 +1146,7 @@
     "Max Requests (including failures)": "最大リクエスト数(失敗を含む)",
     "Max Success": "最大成功数",
     "Max Successful Requests": "最大成功リクエスト数",
+    "Metadata": "メタデータ",
     "Max requests per period": "期間あたりの最大リクエスト数",
     "Max successful requests": "最大成功リクエスト数",
     "Maximum 1000 characters. Supports Markdown and HTML.": "最大1000文字。MarkdownとHTMLをサポートしています。",
@@ -1218,8 +1240,8 @@
     "Network proxy for this channel (supports socks5 protocol)": "このチャネルのネットワークプロキシ (socks5プロトコルをサポート)",
     "Never": "しない",
     "Never expires": "無期限",
-    "New API": "New API",
-    "New API &lt;[email protected]&gt;": "New API &lt;[email protected]&gt;",
+    "New API": "新しいAPI",
+    "New API &lt;[email protected]&gt;": "新しいAPI __ PH_0 __",
     "New API Project Repository:": "New APIプロジェクトリポジトリ:",
     "New Format Template": "新しいフォーマットテンプレート",
     "New Group": "新しいグループ",
@@ -1335,7 +1357,7 @@
     "Old Format Template": "旧形式テンプレート",
     "Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "旧形式: 直接上書き。新形式: 条件判定とカスタムJSON操作をサポートします。",
     "Ollama": "Ollama",
-    "One API": "One API",
+    "One API": "1つのAPI",
     "One IP or CIDR range per line": "1行に1つのIPまたはCIDR範囲",
     "One IP per line (empty for no restriction)": "1行に1つのIP (制限なしの場合は空欄)",
     "One domain per line": "1行に1つのドメイン",
@@ -1566,6 +1588,8 @@
     "Quota:": "クォータ:",
     "Random": "ランダム",
     "Randomly select a key from the pool for each request": "各リクエストごとにプールからランダムにキーを選択",
+    "Received": "Received",
+    "Rewards will be directly added to your account balance": "特典はアカウントの残高に直接追加されます",
     "Rate Limiting": "レート制限",
     "Ratio": "倍率",
     "Ratio Type": "比率タイプ",
@@ -1679,6 +1703,7 @@
     "Save Backup Codes": "バックアップコードを保存",
     "Save Changes": "変更を保存",
     "Save Creem settings": "Creem設定を保存",
+    "Save io.net settings": "io.net設定を保存",
     "Save Models": "モデルを保存",
     "Save SSRF settings": "SSRF 設定を保存",
     "Save Settings": "設定を保存",
@@ -1712,6 +1737,8 @@
     "Secret Key": "シークレットキー",
     "Secure & Reliable": "セキュア&信頼性",
     "Security": "セキュリティ",
+    "Security Check": "セキュリティチェック",
+    "Please complete the security check to continue.": "続行するにはセキュリティチェックを完了してください。",
     "Select": "選択",
     "Select Language": "言語を選択",
     "Select Model": "モデルを選択",
@@ -1939,6 +1966,7 @@
     "This model has both fixed price and ratio billing conflicts": "このモデルには固定価格と比率請求の両方の競合があります",
     "This model is not available in any group, or no group pricing information is configured.": "このモデルはどのグループでも利用できないか、グループの料金情報が設定されていません。",
     "This page has not been created yet.": "このページはまだ作成されていません。",
+    "This month": "今月",
     "This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
     "This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
     "This will delete all": "これによりすべて削除されます",
@@ -1973,6 +2001,8 @@
     "Topup Amount": "チャージ額",
     "Total Count": "合計数",
     "Total Earned": "総収益",
+    "Total check-ins": "総チェックイン数",
+    "Total earned": "总收入",
     "Total Quota": "合計クォータ",
     "Total Tokens": "合計トークン",
     "Total Usage": "総使用量",
@@ -2049,8 +2079,8 @@
     "Upstream ratios fetched successfully": "アップストリーム比率が正常に取得されました",
     "Upstream sync": "アップストリーム同期",
     "Uptime": "稼働時間",
-    "Uptime Kuma": "Uptime Kuma",
-    "Uptime Kuma URL": "Uptime Kuma URL",
+    "Uptime Kuma": "稼働時間Kuma",
+    "Uptime Kuma URL": "稼働時間KUMA URL",
     "Uptime Kuma groups saved successfully": "Uptime Kuma グループが正常に保存されました",
     "Uptime since": "稼働開始日時",
     "Usage Logs": "利用履歴",
@@ -2149,7 +2179,7 @@
     "Weight": "ウェイト",
     "Welcome back!": "おかえりなさい!",
     "Welcome to our New API...": "New API へようこそ...",
-    "Well-Known URL": "Well-Known URL",
+    "Well-Known URL": "よく知られたURL",
     "Well-Known URL must start with http:// or https://": "Well-Known URL は http:// または https:// で始まる必要があります",
     "What would you like to know?": "何を知りたいですか?",
     "When enabled, if channels in the current group fail, it will try channels in the next group in order.": "有効にすると、現在のグループのチャンネルが失敗した場合、次のグループのチャンネルを順番に試します。",
@@ -2161,7 +2191,7 @@
     "Whitelist (Only allow listed domains)": "ホワイトリスト (リストされたドメインのみを許可)",
     "Worker Access Key": "Workerアクセスキー",
     "Worker Proxy": "Workerプロキシ",
-    "Worker URL": "Worker URL",
+    "Worker URL": "ワーカーURL",
     "Workspaces": "ワークスペース",
     "Xinference": "Xinference",
     "Xunfei": "Xunfei",
@@ -2169,6 +2199,7 @@
     "You are about to delete {{count}} API key(s).": "{{count}}個のAPIキーを削除しようとしています。",
     "You can close this tab once the binding completes or a success message appears in the original window.": "バインディングが完了するか、元のウィンドウに成功メッセージが表示されたら、このタブを閉じることができます。",
     "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "\"カスタムモデル名\"で手動で追加し、\"入力\"をクリックしてから送信するか、以下の操作を使用して自動的に処理できます。",
+    "You can only check in once per day": "チェックインできるのは1日1回のみです",
     "You don't have necessary permission": "必要な権限がありません",
     "You have unsaved changes": "未保存の変更があります",
     "You have unsaved changes. Are you sure you want to leave?": "未保存の変更があります。離れてもよろしいですか?",
@@ -2188,10 +2219,10 @@
     "Your Turnstile site key": "あなたのTurnstileサイトキー",
     "Your system access token for API authentication. Keep it secure and don't share it with others.": "API認証用のシステムアクセストークンです。安全に保管し、他者と共有しないでください。",
     "Zhipu": "Zhipu",
-    "Zhipu V4": "Zhipu V4",
+    "Zhipu V4": "Zhipu V 4",
     "Zoom": "ズーム",
     "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
-    "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
+    "[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\" name支付宝 \":\"\",\" type \":\" alipay \",\" color \":\"# 1677 FF \"}]",
     "_copy": "_copy",
     "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": "`, および `-nothinking` サフィックスを、正しい Gemini バリアントにルーティングする際に使用します。",
     "active users": "アクティブユーザー",
@@ -2221,7 +2252,7 @@
     "e.g., Basic Package": "例: 基本パッケージ",
     "e.g., CN2 GIA": "例: CN2 GIA",
     "e.g., Core APIs, OpenAI, Claude": "例: Core APIs、OpenAI、Claude",
-    "e.g., OpenAI GPT-4 Production": "e.g., OpenAI GPT-4 Production",
+    "e.g., OpenAI GPT-4 Production": "例: OpenAI GPT -4 Production",
     "e.g., Recommended for China Mainland Users": "例: 中国本土ユーザーにおすすめ",
     "e.g., d6b5da8hk1awo8nap34ube6gh": "例: d6b5da8hk1awo8nap34ube6gh",
     "e.g., default, vip, premium": "例: default、vip、premium",
@@ -2236,8 +2267,8 @@
     "e.g., us-central1 or JSON format for model-specific regions": "例: us-central1 またはモデル固有のリージョンを示す JSON 形式",
     "e.g., v2.1": "例: v2.1",
     "edit_this": "edit_this",
-    "example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
-    "example.com&#10;company.com": "example.com&#10;company.com",
+    "example.com&#10;blocked-site.com": "example.com &#10;blocked-site.com",
+    "example.com&#10;company.com": "example.com &#10;company.com",
     "expired": "期限切れ",
     "field": "フィールド",
     "footer.columns.about.links.aboutProject": "プロジェクトについて",
@@ -2254,7 +2285,7 @@
     "footer.columns.friends.title": "友達リンク",
     "footer.columns.related.links.midjourney": "Midjourney-Proxy",
     "footer.columns.related.links.neko": "neko-api-key-tool",
-    "footer.columns.related.links.oneApi": "One API",
+    "footer.columns.related.links.oneApi": "1つのAPI",
     "footer.columns.related.title": "関連プロジェクト",
     "footer.defaultCopyright": "すべての権利を留保します。",
     "gpt-3.5-turbo": "gpt-3.5-turbo",
@@ -2333,8 +2364,95 @@
     "with conflicts": "競合あり",
     "x": "x",
     "xAI": "xAI",
-    "{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
+    "{\"original-model\": \"replacement-model\"}": "{\" original - model \":\" replacement - model \"}",
     "| Based on": "| に基づく",
-    "© 2025 Your Company. All rights reserved.": "© 2025 Your Company. 全著作権所有。"
+    "© 2025 Your Company. All rights reserved.": "© 2025 Your Company. 全著作権所有。",
+    "Model deployment service is disabled": "モデルデプロイサービスが無効です",
+    "Configuration required": "設定が必要です",
+    "Please enable io.net model deployment service and configure an API key in System Settings.": "システム設定で io.net モデルデプロイサービスを有効にし、API キーを設定してください。",
+    "Go to settings": "設定へ",
+    "Connection failed": "接続に失敗しました",
+    "Connection error": "接続エラー",
+    "Deleted successfully": "削除しました",
+    "Delete failed": "削除に失敗しました",
+    "Time remaining": "残り時間",
+    "Search deployments...": "デプロイを検索...",
+    "Create deployment": "デプロイを作成",
+    "No data": "データがありません",
+    "Total {{total}}": "合計 {{total}}",
+    "Confirm delete": "削除の確認",
+    "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "デプロイ \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。",
+    "Deployment logs": "デプロイログ",
+    "Deployment ID": "デプロイ ID",
+    "Download": "ダウンロード",
+    "No logs": "ログがありません",
+    "Deployment created successfully": "デプロイを作成しました",
+    "Failed to create deployment": "デプロイの作成に失敗しました",
+    "Container name": "コンテナ名",
+    "Hardware type": "ハードウェアタイプ",
+    "Deployment location": "デプロイ先",
+    "Select locations": "ロケーションを選択",
+    "GPU count": "GPU 数",
+    "Replica count": "レプリカ数",
+    "Duration (hours)": "期間(時間)",
+    "Price estimation": "料金見積もり",
+    "Calculating...": "計算中...",
+    "Not calculated": "未計算",
+    "Advanced configuration": "詳細設定",
+    "Environment variables (JSON)": "環境変数(JSON)",
+    "Secret environment variables (JSON)": "シークレット環境変数(JSON)",
+    "Submitting...": "送信中...",
+    "io.net Deployments": "io.net デプロイ",
+    "Configure io.net API key for model deployments": "モデルデプロイ用の io.net API キーを設定します",
+    "Enable io.net deployments": "io.net デプロイを有効化",
+    "Enable io.net model deployment service in console": "コンソールで io.net モデルデプロイサービスを有効化",
+    "Enter API Key": "API キーを入力",
+    "Used to authenticate with io.net deployment API": "io.net デプロイ API の認証に使用します",
+    "Connection successful": "接続に成功しました",
+    "Connected to io.net service normally.": "io.net サービスに正常に接続しました。",
+    "Ollama Models": "Ollamaモデル",
+    "Manage Ollama Models": "オラマモデルの管理",
+    "Manage local models for:": "次のローカルモデルを管理します。",
+    "This channel is not an Ollama channel.": "このチャンネルはOllamaチャンネルではありません。",
+    "Pull model": "モデルをプル",
+    "Pull": "プル",
+    "Pulling...": "プル中...",
+    "Status:": "ステータス:",
+    "Local models": "ローカルモデル",
+    "Select models and apply to channel models list.": "モデルを選択し、チャンネルモデルリストに適用します。",
+    "Select all (filtered)": "フィルタ結果をすべて選択(S)",
+    "Append to channel": "チャンネルに追加",
+    "Replace channel models": "チャネルモデルを置き換える",
+    "No models found.": "モデルが見つかりません。",
+    "Models appended successfully": "モデルが正常に追加されました",
+    "Please enter model name": "サブモデルを入力してください",
+    "Please set Ollama API Base URL first": "最初にOllama APIベースURLを設定してください",
+    "Model pull failed: {{msg}}": "モデルのプルに失敗しました: __ PH_0 __",
+    "Model deleted": "モデルが削除されました",
+    "AWS Key Format": "AWSキーフォーマット",
+    "Select key format": "キーフォーマットを選択",
+    "AccessKey / SecretAccessKey": "AccessKey/SecretAccessKey",
+    "API Key mode: use APIKey|Region": "APIキーモード: use APIKey | Region",
+    "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SKモード: AccessKey | SecretAccessKey | Regionを使用",
+    "Field passthrough controls": "フィールドパススルーコントロール",
+    "These toggles affect whether certain request fields are passed through to the upstream provider.": "これらの切り替えは、特定の要求フィールドがアップストリームプロバイダーに渡されるかどうかに影響します。",
+    "Allow service_tier passthrough": "Service_tierパススルーを許可する",
+    "Pass through the service_tier field": "SERVICE_TIERフィールドを通過する",
+    "Disable store passthrough": "ストアパススルーを無効にする",
+    "When enabled, the store field will be blocked": "有効にすると、ストアフィールドはブロックされます",
+    "Allow safety_identifier passthrough": "SAFETY_IDENTIFIERパススルーを許可する",
+    "Pass through the safety_identifier field": "SAFETY_IDENTIFIERフィールドを通過する",
+    "From IO.NET deployment": "IO.NET展開から",
+    "Click to open deployment": "クリックして展開を開く",
+    "Enter API Key, one per line, format: APIKey|Region": "APIキーを1行に1つずつ入力してください。形式: APIKey | Region",
+    "Enter API Key, format: APIKey|Region": "APIキーを入力してください。形式: APIKey | Region",
+    "Enter key, one per line, format: AccessKey|SecretAccessKey|Region": "キーを入力してください。1行に1つ、形式: AccessKey | SecretAccessKey | Region",
+    "Enter key, format: AccessKey|SecretAccessKey|Region": "キーを入力してください、形式: AccessKey | SecretAccessKey | Region",
+    "Service account JSON file(s)": "サービスアカウントJSONファイル",
+    "Please upload key file(s)": "キーファイルをアップロードしてください",
+    "Failed to parse JSON file: {{name}}": "JSONファイルの解析に失敗しました: __ PH_0 __",
+    "Parsed {{count}} service account file(s)": "__ PH_0 __サービスアカウントファイルを解析しました",
+    "Upload multiple JSON files in batch modes": "バッチモードで複数のJSONファイルをアップロードする",
+    "Upload a single service account JSON file": "単一のサービスアカウントJSONファイルをアップロードする"
   }
 }

+ 126 - 8
web/src/i18n/locales/ru.json

@@ -38,7 +38,7 @@
     "A powerful API proxy service supporting OpenAI, Claude, Gemini and other mainstream AI models, helping you easily manage and call various API services": "Мощный прокси-сервис API, поддерживающий OpenAI, Claude, Gemini и другие основные модели ИИ, помогающий легко управлять и вызывать различные API-сервисы",
     "AGPL v3.0 License": "Лицензия AGPL v3.0",
     "AI Proxy": "AI Proxy",
-    "AI Proxy Library": "AI Proxy Library",
+    "AI Proxy Library": "Библиотека прокси AI",
     "AI models": "Модели ИИ",
     "AI models supported": "Поддерживаемые модели ИИ",
     "AIGC2D": "AIGC2D",
@@ -295,6 +295,23 @@
     "Change Password": "Изменить пароль",
     "Change To": "Изменить на",
     "Change language": "Изменить язык",
+    "Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
+    "Check in now": "Войдите сейчас",
+    "Check-in Rules": "Правила прибытия",
+    "Check-in Settings": "Настройки прибытия",
+    "Check-in failed": "Регистрация не удалась.",
+    "Check-in successful! Received": "Прибытие прошло успешно! Получено",
+    "Checked in today": "Зарегистрирован вход сегодня",
+    "Checked in today, total check-ins": "Прибытие сегодня, всего прибытий",
+    "Checking connection...": "Проверка соединения...",
+    "Configure daily check-in rewards for users": "Настроить ежедневные награды за регистрацию для пользователей",
+    "Enable check-in feature": "Включить функцию прибытия",
+    "Allow users to check in daily for random quota rewards": "Разрешить пользователям регистрироваться ежедневно для получения случайных квотных наград",
+    "Minimum check-in quota": "Минимальная квота регистрации",
+    "Maximum check-in quota": "Максимальная квота регистрации",
+    "Minimum quota amount awarded for check-in": "Минимальная сумма квоты, присуждаемая за регистрацию",
+    "Maximum quota amount awarded for check-in": "Максимальная сумма квоты, присуждаемая за регистрацию",
+    "Save check-in settings": "Сохранить настройки прибытия",
     "Channel": "Канал",
     "Channel Extra Settings": "Дополнительные настройки канала",
     "Channel ID": "ID канала",
@@ -524,6 +541,7 @@
     "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Пользовательские множители, когда определенные группы пользователей используют определенные группы токенов. Пример: VIP-пользователи получают ставку 0.9x при использовании токенов группы \"edit_this\".",
     "Dark": "Тёмная",
     "Dashboard": "Панель управления",
+    "Daily Check-in": "Ежедневный вход",
     "Data Dashboard": "Панель мониторинга данных",
     "Data directory:": "Каталог данных:",
     "Data is stored locally on this device. Use system backups to keep a safe copy.": "Данные хранятся локально на этом устройстве. Используйте системные резервные копии для сохранения безопасной копии.",
@@ -531,6 +549,7 @@
     "Date and time when this announcement should be displayed": "Дата и время отображения этого объявления",
     "DeepSeek": "DeepSeek",
     "Default": "По умолчанию",
+    "Deployments": "Развертывания",
     "Default API Version *": "Версия API по умолчанию *",
     "Default API version for this channel": "Версия API по умолчанию для этого канала",
     "Default Collapse Sidebar": "Сворачивать боковую панель по умолчанию",
@@ -615,7 +634,7 @@
     "Domain Filter Mode": "Режим фильтра домена",
     "Don't have an account?": "У вас нет аккаунта?",
     "Done": "Готово",
-    "Doubao Coding Plan": "Doubao Coding Plan",
+    "Doubao Coding Plan": "План кодирования Doubao",
     "Doubao custom API address editing unlocked": "Редактирование пользовательского адреса API Doubao разблокировано",
     "DoubaoVideo": "DoubaoVideo",
     "Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
@@ -772,6 +791,7 @@
     "Failed": "Неудача",
     "Failed to apply overwrite.": "Не удалось применить перезапись.",
     "Failed to bind email": "Не удалось привязать email",
+    "Failed to fetch checkin status": "Не удалось получить статус регистрации",
     "Failed to change password": "Не удалось изменить пароль",
     "Failed to clean logs": "Не удалось очистить логи",
     "Failed to complete Passkey login": "Не удалось завершить вход с Passkey",
@@ -1100,6 +1120,7 @@
     "Make it easier for teammates to pick the right group.": "Упростите выбор правильной группы для товарищей по команде.",
     "Manage": "Управление",
     "Manage AI model metadata and vendor configurations": "Управление метаданными AI-моделей и конфигурациями поставщиков",
+    "Manage model metadata and deployments": "Управление метаданными моделей и развертываниями",
     "Manage API channels and provider configurations": "Управление каналами API и конфигурациями провайдеров",
     "Manage Keys": "Управление ключами",
     "Manage Vendors": "Управление поставщиками",
@@ -1125,6 +1146,7 @@
     "Max Requests (including failures)": "Макс. запросов (включая сбои)",
     "Max Success": "Макс. успешных",
     "Max Successful Requests": "Макс. успешных запросов",
+    "Metadata": "Метаданные",
     "Max requests per period": "Макс. запросов за период",
     "Max successful requests": "Макс. успешных запросов",
     "Maximum 1000 characters. Supports Markdown and HTML.": "Максимум 1000 символов. Поддерживает Markdown и HTML.",
@@ -1218,8 +1240,8 @@
     "Network proxy for this channel (supports socks5 protocol)": "Сетевой прокси для этого канала (поддерживает протокол socks5)",
     "Never": "Никогда",
     "Never expires": "Никогда не истекает",
-    "New API": "New API",
-    "New API &lt;[email protected]&gt;": "New API &lt;[email protected]&gt;",
+    "New API": "Новый API",
+    "New API &lt;[email protected]&gt;": "Новый API &lt;[email protected]&gt;",
     "New API Project Repository:": "Репозиторий проекта New API:",
     "New Format Template": "Новый шаблон формата",
     "New Group": "Новая группа",
@@ -1566,6 +1588,8 @@
     "Quota:": "Квота:",
     "Random": "Случайный",
     "Randomly select a key from the pool for each request": "Случайно выбирать ключ из пула для каждого запроса",
+    "Received": "Received",
+    "Rewards will be directly added to your account balance": "Бонусы будут добавлены на баланс вашего аккаунта",
     "Rate Limiting": "Ограничение скорости",
     "Ratio": "Коэффициент",
     "Ratio Type": "Тип соотношения",
@@ -1679,6 +1703,7 @@
     "Save Backup Codes": "Сохранить резервные коды",
     "Save Changes": "Сохранить изменения",
     "Save Creem settings": "Сохранить настройки Creem",
+    "Save io.net settings": "Сохранить настройки io.net",
     "Save Models": "Сохранить модели",
     "Save SSRF settings": "Сохранить настройки SSRF",
     "Save Settings": "Сохранить настройки",
@@ -1712,6 +1737,8 @@
     "Secret Key": "Секретный ключ",
     "Secure & Reliable": "Безопасно и надежно",
     "Security": "Безопасность",
+    "Security Check": "Проверка безопасности",
+    "Please complete the security check to continue.": "Пожалуйста, завершите проверку безопасности, чтобы продолжить.",
     "Select": "Выбрать",
     "Select Language": "Выбрать язык",
     "Select Model": "Выбрать модель",
@@ -1939,6 +1966,7 @@
     "This model has both fixed price and ratio billing conflicts": "Эта модель имеет конфликты как фиксированной цены, так и пропорциональной тарификации",
     "This model is not available in any group, or no group pricing information is configured.": "Эта модель недоступна ни в одной группе, или информация о ценах для групп не настроена.",
     "This page has not been created yet.": "Эта страница еще не создана.",
+    "This month": "В этом месяце",
     "This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
     "This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
     "This will delete all": "Это удалит все",
@@ -1973,6 +2001,8 @@
     "Topup Amount": "Сумма пополнения",
     "Total Count": "Общее количество",
     "Total Earned": "Всего заработано",
+    "Total check-ins": "Общая проверка",
+    "Total earned": "Итого заработано",
     "Total Quota": "Общая квота",
     "Total Tokens": "Всего токенов",
     "Total Usage": "Общее использование",
@@ -2049,7 +2079,7 @@
     "Upstream ratios fetched successfully": "Коэффициенты upstream успешно получены",
     "Upstream sync": "Синхронизация Upstream",
     "Uptime": "Время работы",
-    "Uptime Kuma": "Uptime Kuma",
+    "Uptime Kuma": "Время безотказной работы Kuma",
     "Uptime Kuma URL": "URL Uptime Kuma",
     "Uptime Kuma groups saved successfully": "Группы Uptime Kuma успешно сохранены",
     "Uptime since": "Время работы с",
@@ -2149,7 +2179,7 @@
     "Weight": "Вес",
     "Welcome back!": "Добро пожаловать обратно!",
     "Welcome to our New API...": "Добро пожаловать в наш New API...",
-    "Well-Known URL": "Well-Known URL",
+    "Well-Known URL": "Известный эксперт",
     "Well-Known URL must start with http:// or https://": "Well-Known URL должен начинаться с http:// или https://",
     "What would you like to know?": "Что вы хотели бы узнать?",
     "When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Если включено, при сбое каналов в текущей группе система попробует каналы следующей группы по порядку.",
@@ -2169,6 +2199,7 @@
     "You are about to delete {{count}} API key(s).": "Вы собираетесь удалить {{count}} API-ключ(а/ей).",
     "You can close this tab once the binding completes or a success message appears in the original window.": "Вы можете закрыть эту вкладку, как только привязка завершится или в исходном окне появится сообщение об успехе.",
     "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Вы можете вручную добавить их в \"Пользовательские имена моделей\", нажать \"Заполнить\", а затем отправить, или использовать операции ниже для автоматической обработки.",
+    "You can only check in once per day": "Вы можете заселяться только один раз в день",
     "You don't have necessary permission": "У вас нет необходимых разрешений",
     "You have unsaved changes": "У вас есть несохранённые изменения",
     "You have unsaved changes. Are you sure you want to leave?": "У вас есть несохранённые изменения. Вы уверены, что хотите уйти?",
@@ -2254,7 +2285,7 @@
     "footer.columns.friends.title": "Дружественные ссылки",
     "footer.columns.related.links.midjourney": "Midjourney-Proxy",
     "footer.columns.related.links.neko": "neko-api-key-tool",
-    "footer.columns.related.links.oneApi": "One API",
+    "footer.columns.related.links.oneApi": "Один API",
     "footer.columns.related.title": "Связанные проекты",
     "footer.defaultCopyright": "Все права защищены.",
     "gpt-3.5-turbo": "gpt-3.5-turbo",
@@ -2335,6 +2366,93 @@
     "xAI": "xAI",
     "{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
     "| Based on": "| На основе",
-    "© 2025 Your Company. All rights reserved.": "© 2025 Ваша Компания. Все права защищены."
+    "© 2025 Your Company. All rights reserved.": "© 2025 Ваша Компания. Все права защищены.",
+    "Model deployment service is disabled": "Сервис развертывания моделей отключен",
+    "Configuration required": "Требуется настройка",
+    "Please enable io.net model deployment service and configure an API key in System Settings.": "Пожалуйста, включите сервис развертывания моделей io.net и настройте API-ключ в системных настройках.",
+    "Go to settings": "Перейти к настройкам",
+    "Connection failed": "Не удалось подключиться",
+    "Connection error": "Ошибка соединения",
+    "Deleted successfully": "Удалено успешно",
+    "Delete failed": "Не удалось удалить",
+    "Time remaining": "Осталось времени",
+    "Search deployments...": "Поиск развертываний...",
+    "Create deployment": "Создать развертывание",
+    "No data": "Нет данных",
+    "Total {{total}}": "Всего {{total}}",
+    "Confirm delete": "Подтвердить удаление",
+    "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Вы уверены, что хотите удалить развертывание \"{{name}}\"? Это действие нельзя отменить.",
+    "Deployment logs": "Логи развертывания",
+    "Deployment ID": "ID развертывания",
+    "Download": "Скачать",
+    "No logs": "Нет логов",
+    "Deployment created successfully": "Развертывание создано успешно",
+    "Failed to create deployment": "Не удалось создать развертывание",
+    "Container name": "Имя контейнера",
+    "Hardware type": "Тип оборудования",
+    "Deployment location": "Локация развертывания",
+    "Select locations": "Выбрать локации",
+    "GPU count": "Количество GPU",
+    "Replica count": "Количество реплик",
+    "Duration (hours)": "Длительность (часы)",
+    "Price estimation": "Оценка стоимости",
+    "Calculating...": "Вычисление...",
+    "Not calculated": "Не рассчитано",
+    "Advanced configuration": "Расширенная конфигурация",
+    "Environment variables (JSON)": "Переменные окружения (JSON)",
+    "Secret environment variables (JSON)": "Секретные переменные окружения (JSON)",
+    "Submitting...": "Отправка...",
+    "io.net Deployments": "Развертывания io.net",
+    "Configure io.net API key for model deployments": "Настройте API-ключ io.net для развертывания моделей",
+    "Enable io.net deployments": "Включить развертывания io.net",
+    "Enable io.net model deployment service in console": "Включить сервис развертывания моделей io.net в консоли",
+    "Enter API Key": "Введите API-ключ",
+    "Used to authenticate with io.net deployment API": "Используется для аутентификации в API развертывания io.net",
+    "Connection successful": "Подключение успешно",
+    "Connected to io.net service normally.": "Соединение с сервисом io.net установлено.",
+    "Ollama Models": "Ollama Модели",
+    "Manage Ollama Models": "Управление моделями Ollama",
+    "Manage local models for:": "Управление локальными моделями для:",
+    "This channel is not an Ollama channel.": "Этот канал не является каналом Ollama.",
+    "Pull model": "Загрузить модель",
+    "Pull": "Загрузить",
+    "Pulling...": "Загрузка...",
+    "Status:": "Статус:",
+    "Local models": "Локальные модели",
+    "Select models and apply to channel models list.": "Выберите модели и примените к списку моделей каналов.",
+    "Select all (filtered)": "& Выбрать все отфильтрованные",
+    "Append to channel": "Добавить в канал",
+    "Replace channel models": "Замена моделей каналов",
+    "No models found.": "Модели автомобиля не найдены.",
+    "Models appended successfully": "Модели успешно добавлены",
+    "Please enter model name": "Введите название модели",
+    "Please set Ollama API Base URL first": "Сначала установите базовый URL-адрес API Ollama",
+    "Model pull failed: {{msg}}": "Ошибка тяги модели: {{msg}}",
+    "Model deleted": "Модель удалена",
+    "AWS Key Format": "Формат ключа AWS",
+    "Select key format": "Выберите формат ключа",
+    "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey",
+    "API Key mode: use APIKey|Region": "Режим API Key: use APIKey|Region",
+    "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Режим AK/SK: используйте AccessKey|SecretAccessKey|Region",
+    "Field passthrough controls": "Полевые сквозные элементы управления",
+    "These toggles affect whether certain request fields are passed through to the upstream provider.": "Эти переключатели влияют на то, передаются ли определенные поля запроса вышестоящему поставщику.",
+    "Allow service_tier passthrough": "Разрешить сквозную передачу service_tier",
+    "Pass through the service_tier field": "Пройдите через поле service_tier",
+    "Disable store passthrough": "Отключить сквозной переход магазина",
+    "When enabled, the store field will be blocked": "Если включено, поле магазина будет заблокировано",
+    "Allow safety_identifier passthrough": "Разрешить сквозную передачу Safety_Identifier",
+    "Pass through the safety_identifier field": "Пройдите через поле safety_identifier",
+    "From IO.NET deployment": "Из развертывания IO.NET",
+    "Click to open deployment": "Нажмите, чтобы открыть развертывание",
+    "Enter API Key, one per line, format: APIKey|Region": "Введите ключ API, по одному на строку, формат: APIKey|Region",
+    "Enter API Key, format: APIKey|Region": "Введите ключ API, формат: APIKey|Region",
+    "Enter key, one per line, format: AccessKey|SecretAccessKey|Region": "Введите ключ, по одному на строку, формат: AccessKey|SecretAccessKey|Region",
+    "Enter key, format: AccessKey|SecretAccessKey|Region": "Введите ключ, формат: AccessKey|SecretAccessKey|Region",
+    "Service account JSON file(s)": "JSON-файл сервисного аккаунта",
+    "Please upload key file(s)": "Загрузите ключевой файл(ы)",
+    "Failed to parse JSON file: {{name}}": "Не удалось проанализировать файл JSON: {{name}}",
+    "Parsed {{count}} service account file(s)": "Проанализировано файлов сервисного аккаунта {{count}}",
+    "Upload multiple JSON files in batch modes": "Загрузка нескольких файлов JSON в пакетных режимах",
+    "Upload a single service account JSON file": "Загрузите JSON-файл одного сервисного аккаунта"
   }
 }

+ 127 - 9
web/src/i18n/locales/vi.json

@@ -1,7 +1,7 @@
 {
   "translation": {
     "360": "360",
-    "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1",
+    "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"",
     "% off": "% off",
     "(Leave empty to dissolve tag)": "Để trống để xóa thẻ.",
     "(Optional: redirect model names)": "(Tùy chọn: chuyển hướng tên mô hình)",
@@ -185,7 +185,7 @@
     "Apply Overwrite": "Áp dụng Ghi đè",
     "Apply Sync": "Áp dụng đồng bộ",
     "Applying...": "Đang áp dụng...",
-    "Are you sure you want to delete": "Are you sure you want to delete",
+    "Are you sure you want to delete": "Bạn có chắc chắn muốn xóa ",
     "Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Bạn có chắc chắn muốn xóa tất cả các khóa bị tắt tự động? Hành động này không thể hoàn tác.",
     "Are you sure you want to delete this key? This action cannot be undone.": "Bạn có chắc chắn muốn xóa khóa này? Hành động này không thể hoàn tác.",
     "Are you sure you want to disable all enabled keys?": "Bạn có chắc chắn muốn vô hiệu hóa tất cả các khóa đang bật không?",
@@ -295,6 +295,23 @@
     "Change Password": "Đổi mật khẩu",
     "Change To": "Thay đổi thành",
     "Change language": "Đổi ngôn ngữ",
+    "Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên",
+    "Check in now": "Check in now",
+    "Check-in Rules": "Quy tắc điểm danh",
+    "Check-in Settings": "Cài đặt điểm danh",
+    "Check-in failed": "Điểm danh thất bại",
+    "Check-in successful! Received": "Điểm danh thành công! Đã nhận",
+    "Checked in today": "Đã điểm danh hôm nay",
+    "Checked in today, total check-ins": "Đã điểm danh hôm nay, tổng số lần điểm danh",
+    "Checking connection...": "Đang kiểm tra kết nối...",
+    "Configure daily check-in rewards for users": "Cấu hình phần thưởng điểm danh hàng ngày cho người dùng",
+    "Enable check-in feature": "Bật tính năng điểm danh",
+    "Allow users to check in daily for random quota rewards": "Cho phép người dùng điểm danh hàng ngày để nhận phần thưởng hạn ngạch ngẫu nhiên",
+    "Minimum check-in quota": "Hạn ngạch điểm danh tối thiểu",
+    "Maximum check-in quota": "Hạn ngạch điểm danh tối đa",
+    "Minimum quota amount awarded for check-in": "Số lượng hạn ngạch tối thiểu được trao cho điểm danh",
+    "Maximum quota amount awarded for check-in": "Số lượng hạn ngạch tối đa được trao cho điểm danh",
+    "Save check-in settings": "Lưu cài đặt điểm danh",
     "Channel": "Kênh",
     "Channel Extra Settings": "Cài đặt thêm kênh",
     "Channel ID": "Mã kênh",
@@ -401,7 +418,7 @@
     "Configure discount rates based on recharge amounts": "Cấu hình tỷ lệ chiết khấu dựa trên số tiền nạp",
     "Configure experimental data export for the dashboard": "Cấu hình xuất dữ liệu thử nghiệm cho bảng điều khiển",
     "Configure in your Creem dashboard": "Cấu hình trong bảng điều khiển Creem của bạn",
-    "Configure keyword filtering for prompts and responses.": "Configure keyword filtering for prompts and responses.",
+    "Configure keyword filtering for prompts and responses.": "Định cấu hình lọc từ khóa để xem lời nhắc và câu trả lời.",
     "Configure model, caching, and group ratios used for billing": "Cấu hình mô hình, bộ nhớ đệm và tỷ lệ nhóm được sử dụng để tính phí.",
     "Configure monitoring status page groups for the dashboard": "Cấu hình các nhóm trang trạng thái giám sát cho bảng điều khiển",
     "Configure outgoing email server for notifications": "Cấu hình máy chủ email gửi đi cho thông báo",
@@ -524,6 +541,7 @@
     "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Các hệ số nhân tùy chỉnh khi các nhóm người dùng cụ thể sử dụng các nhóm token cụ thể. Ví dụ: Người dùng VIP được hưởng tỷ lệ 0.9x khi sử dụng các token thuộc nhóm \"edit_this\".",
     "Dark": "Tối",
     "Dashboard": "Bảng điều khiển",
+    "Daily Check-in": "Daily Check-in",
     "Data Dashboard": "Data Dashboard",
     "Data directory:": "Thư mục dữ liệu:",
     "Data is stored locally on this device. Use system backups to keep a safe copy.": "Dữ liệu được lưu trữ cục bộ trên thiết bị này. Sử dụng tính năng sao lưu hệ thống để giữ một bản",
@@ -531,6 +549,7 @@
     "Date and time when this announcement should be displayed": "The date and time this notification should be displayed",
     "DeepSeek": "DeepSeek",
     "Default": "Mặc định",
+    "Deployments": "Triển khai",
     "Default API Version *": "Phiên bản API mặc định *",
     "Default API version for this channel": "Phiên bản API mặc định cho kênh này",
     "Default Collapse Sidebar": "Mặc định Thu gọn Thanh bên",
@@ -706,7 +725,7 @@
     "Enter new key to update, or leave empty to keep current key": "Nhập khóa mới để cập nhật, hoặc để trống để giữ khóa hiện tại",
     "Enter new tag name (leave empty to disband tag)": "Nhập tên thẻ mới (để trống để hủy thẻ)",
     "Enter new tag name or leave empty": "Enter new tag name or leave blank",
-    "Enter new token to update": "Enter new token to update",
+    "Enter new token to update": "Nhập mã thông báo mới để cập nhật",
     "Enter one API key per line for batch creation": "Nhập một khóa API mỗi dòng để tạo hàng loạt",
     "Enter one keyword per line": "Nhập một từ khóa mỗi dòng",
     "Enter password": "Nhập mật khẩu",
@@ -772,6 +791,7 @@
     "Failed": "Thất bại",
     "Failed to apply overwrite.": "Không thể áp dụng ghi đè.",
     "Failed to bind email": "Không thể liên kết email",
+    "Failed to fetch checkin status": "Không thể tải trạng thái điểm danh",
     "Failed to change password": "Không thể thay đổi mật khẩu",
     "Failed to clean logs": "Không thể dọn sạch nhật ký",
     "Failed to complete Passkey login": "Không thể hoàn tất đăng nhập Passkey",
@@ -810,7 +830,7 @@
     "Failed to load image": "Không thể tải ảnh",
     "Failed to load profile": "Không thể tải hồ sơ",
     "Failed to load redemption codes": "Không thể tải mã đổi thưởng",
-    "Failed to load setup data": "Failed to load setup data",
+    "Failed to load setup data": "Không thể tải dữ liệu thiết lập",
     "Failed to load tag data": "Không thể tải dữ liệu thẻ",
     "Failed to parse group items": "Thất bại khi phân tích các mục nhóm",
     "Failed to query balance": "Không thể truy vấn số dư",
@@ -1100,6 +1120,7 @@
     "Make it easier for teammates to pick the right group.": "Giúp đồng đội dễ dàng chọn đúng nhóm hơn.",
     "Manage": "Quản lý",
     "Manage AI model metadata and vendor configurations": "Quản lý siêu dữ liệu mô hình AI và cấu hình nhà cung cấp",
+    "Manage model metadata and deployments": "Quản lý siêu dữ liệu mô hình và triển khai",
     "Manage API channels and provider configurations": "Quản lý các kênh API và cấu hình nhà cung cấp",
     "Manage Keys": "Quản lý Khóa",
     "Manage Vendors": "Quản lý Nhà cung cấp",
@@ -1125,6 +1146,7 @@
     "Max Requests (including failures)": "Số yêu cầu tối đa (bao gồm cả các lỗi)",
     "Max Success": "Thành công tối đa",
     "Max Successful Requests": "Yêu cầu thành công tối đa",
+    "Metadata": "Siêu dữ liệu",
     "Max requests per period": "Yêu cầu tối đa mỗi khoảng thời gian",
     "Max successful requests": "Số yêu cầu thành công tối đa",
     "Maximum 1000 characters. Supports Markdown and HTML.": "Tối đa 1000 ký tự. Hỗ trợ Markdown và HTML.",
@@ -1194,7 +1216,7 @@
     "Multi-Key Strategy": "Chiến lược đa khóa",
     "Multi-key channel: Keys will be": "Kênh đa khóa: Các khóa sẽ là",
     "Multi-region deployment for stable global access": "Triển khai đa khu vực để truy cập toàn cầu ổn định",
-    "Multi-user management with flexible permission allocation": "Multi-user management with flexible permission allocation",
+    "Multi-user management with flexible permission allocation": "Quản lý nhiều người dùng với phân bổ quyền linh hoạt",
     "Multiplier": "Hệ số nhân",
     "Multiplier applied when": "Hệ số nhân áp dụng khi",
     "Multiplier for audio inputs.": "Hệ số nhân cho đầu vào âm thanh.",
@@ -1566,6 +1588,8 @@
     "Quota:": "Hạn ngạch:",
     "Random": "Ngẫu nhiên",
     "Randomly select a key from the pool for each request": "Chọn ngẫu nhiên một khóa từ kho cho mỗi yêu cầu",
+    "Received": "Received",
+    "Rewards will be directly added to your account balance": "Phần thưởng sẽ được thêm trực tiếp vào số dư tài khoản của bạn",
     "Rate Limiting": "Rate limit",
     "Ratio": "Tỷ lệ",
     "Ratio Type": "Rate type",
@@ -1679,6 +1703,7 @@
     "Save Backup Codes": "Lưu mã dự phòng",
     "Save Changes": "Lưu Thay đổi",
     "Save Creem settings": "Lưu cài đặt Creem",
+    "Save io.net settings": "Lưu cài đặt io.net",
     "Save Models": "Lưu Mô hình",
     "Save SSRF settings": "Lưu cài đặt SSRF",
     "Save Settings": "Lưu Cài đặt",
@@ -1712,6 +1737,8 @@
     "Secret Key": "Khóa bí mật",
     "Secure & Reliable": "An toàn & Đáng tin cậy",
     "Security": "Bảo mật",
+    "Security Check": "Kiểm tra bảo mật",
+    "Please complete the security check to continue.": "Vui lòng hoàn thành kiểm tra bảo mật để tiếp tục.",
     "Select": "Chọn",
     "Select Language": "Chọn Ngôn ngữ",
     "Select Model": "Chọn mẫu",
@@ -1939,6 +1966,7 @@
     "This model has both fixed price and ratio billing conflicts": "Mô hình này có cả mâu thuẫn về thanh toán theo giá cố định và theo tỷ lệ.",
     "This model is not available in any group, or no group pricing information is configured.": "Mô hình này không khả dụng trong bất kỳ nhóm nào, hoặc thông tin giá nhóm chưa được cấu hình.",
     "This page has not been created yet.": "Trang này chưa được tạo.",
+    "This month": "This month",
     "This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
     "This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.",
     "This will delete all": "Thao tác này sẽ xóa tất cả",
@@ -1973,6 +2001,8 @@
     "Topup Amount": "Số tiền nạp",
     "Total Count": "Tổng số",
     "Total Earned": "Total income",
+    "Total check-ins": "Total check-ins",
+    "Total earned": "Total earned",
     "Total Quota": "Tổng hạn mức",
     "Total Tokens": "Tổng số token",
     "Total Usage": "Tổng Mức Sử dụng",
@@ -2169,6 +2199,7 @@
     "You are about to delete {{count}} API key(s).": "Bạn sắp xóa {{count}} khóa API.",
     "You can close this tab once the binding completes or a success message appears in the original window.": "Bạn có thể đóng tab này sau khi quá trình liên kết hoàn tất hoặc thông báo thành công xuất hiện trong cửa sổ gốc.",
     "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Bạn có thể thêm chúng theo cách thủ công trong \"Tên mô hình tùy chỉnh\", nhấp vào \"Điền\" rồi gửi, hoặc sử dụng các thao tác bên dưới để xử lý tự động.",
+    "You can only check in once per day": "You can only check in once per day",
     "You don't have necessary permission": "Bạn không có quyền cần thiết",
     "You have unsaved changes": "Bạn có thay đổi chưa được lưu",
     "You have unsaved changes. Are you sure you want to leave?": "Bạn có thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?",
@@ -2311,14 +2342,14 @@
     "selected": "đã chọn",
     "selected channel(s). Leave empty to remove tag.": "Kênh đã chọn. Để trống để xóa thẻ.",
     "showing •": "hiển thị •",
-    "sk_xxx or rk_xxx": "sk_xxx or rk_xxx",
+    "sk_xxx or rk_xxx": "sk_xxx hoặc rk_xxx",
     "smtp.example.com": "smtp.example.com",
     "socks5://user:pass@host:port": "socks5://user:pass@host:port",
     "sources": "nguồn",
     "stream": "dòng",
     "times": "lần",
     "to access this resource.": "để truy cập tài nguyên này.",
-    "to confirm": "to confirm",
+    "to confirm": "Chờ xác nhận",
     "to override billing when a user in one group uses a token of another group.": "để ghi đè việc thanh toán khi một người dùng trong một nhóm sử dụng token của một nhóm khác.",
     "to the Models list so users can use them before the mapping sends traffic upstream.": "vào danh sách Mô hình để người dùng có thể sử dụng chúng trước khi ánh xạ gửi lưu lượng truy cập lên phía trên.",
     "to view this resource.": "để xem tài nguyên này.",
@@ -2335,6 +2366,93 @@
     "xAI": "xAI",
     "{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
     "| Based on": "| Dựa trên",
-    "© 2025 Your Company. All rights reserved.": "© 2025 Công ty của bạn. Mọi quyền được bảo lưu."
+    "© 2025 Your Company. All rights reserved.": "© 2025 Công ty của bạn. Mọi quyền được bảo lưu.",
+    "Model deployment service is disabled": "Dịch vụ triển khai mô hình đang bị tắt",
+    "Configuration required": "Cần cấu hình",
+    "Please enable io.net model deployment service and configure an API key in System Settings.": "Vui lòng bật dịch vụ triển khai mô hình io.net và cấu hình khóa API trong Cài đặt hệ thống.",
+    "Go to settings": "Đi tới cài đặt",
+    "Connection failed": "Kết nối thất bại",
+    "Connection error": "Lỗi kết nối",
+    "Deleted successfully": "Xóa thành công",
+    "Delete failed": "Xóa thất bại",
+    "Time remaining": "Thời gian còn lại",
+    "Search deployments...": "Tìm kiếm triển khai...",
+    "Create deployment": "Tạo triển khai",
+    "No data": "Không có dữ liệu",
+    "Total {{total}}": "Tổng {{total}}",
+    "Confirm delete": "Xác nhận xóa",
+    "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Bạn có chắc muốn xóa triển khai \"{{name}}\" không? Hành động này không thể hoàn tác.",
+    "Deployment logs": "Nhật ký triển khai",
+    "Deployment ID": "ID triển khai",
+    "Download": "Tải xuống",
+    "No logs": "Không có nhật ký",
+    "Deployment created successfully": "Tạo triển khai thành công",
+    "Failed to create deployment": "Tạo triển khai thất bại",
+    "Container name": "Tên container",
+    "Hardware type": "Loại phần cứng",
+    "Deployment location": "Vị trí triển khai",
+    "Select locations": "Chọn vị trí",
+    "GPU count": "Số lượng GPU",
+    "Replica count": "Số bản sao",
+    "Duration (hours)": "Thời lượng (giờ)",
+    "Price estimation": "Ước tính chi phí",
+    "Calculating...": "Đang tính...",
+    "Not calculated": "Chưa tính",
+    "Advanced configuration": "Cấu hình nâng cao",
+    "Environment variables (JSON)": "Biến môi trường (JSON)",
+    "Secret environment variables (JSON)": "Biến môi trường bí mật (JSON)",
+    "Submitting...": "Đang gửi...",
+    "io.net Deployments": "Triển khai io.net",
+    "Configure io.net API key for model deployments": "Cấu hình khóa API io.net cho triển khai mô hình",
+    "Enable io.net deployments": "Bật triển khai io.net",
+    "Enable io.net model deployment service in console": "Bật dịch vụ triển khai mô hình io.net trong bảng điều khiển",
+    "Enter API Key": "Nhập khóa API",
+    "Used to authenticate with io.net deployment API": "Dùng để xác thực với API triển khai io.net",
+    "Connection successful": "Kết nối thành công",
+    "Connected to io.net service normally.": "Đã kết nối bình thường tới dịch vụ io.net.",
+    "Ollama Models": "Mô hình Ollama",
+    "Manage Ollama Models": "Quản lý mô hình Ollama",
+    "Manage local models for:": "Quản lý mô hình cục bộ cho:",
+    "This channel is not an Ollama channel.": "Kênh này không phải là kênh Ollama.",
+    "Pull model": "Tải mô hình",
+    "Pull": "Tải",
+    "Pulling...": "Đang tải...",
+    "Status:": "Trạng thái:",
+    "Local models": "Mô hình cục bộ",
+    "Select models and apply to channel models list.": "Chọn mô hình và áp dụng cho danh sách mô hình kênh.",
+    "Select all (filtered)": "Chọn tất cả (đã lọc)",
+    "Append to channel": "Nối vào kênh",
+    "Replace channel models": "Thay thế mô hình kênh",
+    "No models found.": "Không tìm thấy mô hình.",
+    "Models appended successfully": "Đã thêm mô hình thành công",
+    "Please enter model name": "Vui lòng nhập ID hoặc tên mô hình.",
+    "Please set Ollama API Base URL first": "Vui lòng đặt URL cơ sở API Ollama trước",
+    "Model pull failed: {{msg}}": "Tải mô hình thất bại: {{msg}}",
+    "Model deleted": "Đã xóa mô hình",
+    "AWS Key Format": "Định dạng khóa AWS",
+    "Select key format": "Chọn định dạng khóa",
+    "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey",
+    "API Key mode: use APIKey|Region": "Chế độ khóa API: sử dụng APIKey|Region",
+    "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region",
+    "Field passthrough controls": "Điều khiển chuyển tiếp trường",
+    "These toggles affect whether certain request fields are passed through to the upstream provider.": "Các chuyển đổi này ảnh hưởng đến việc các trường yêu cầu nhất định có được chuyển đến nhà cung cấp dịch vụ đầu vào hay không.",
+    "Allow service_tier passthrough": "Cho phép chuyển tiếp service_tier",
+    "Pass through the service_tier field": "Chuyển tiếp trường service_tier",
+    "Disable store passthrough": "Vô hiệu hóa chuyển tiếp store",
+    "When enabled, the store field will be blocked": "Khi được bật, trường store sẽ bị chặn",
+    "Allow safety_identifier passthrough": "Cho phép chuyển tiếp safety_identifier",
+    "Pass through the safety_identifier field": "Chuyển tiếp trường safety_identifier",
+    "From IO.NET deployment": "Từ triển khai IO.NET",
+    "Click to open deployment": "Nhấp để mở triển khai",
+    "Enter API Key, one per line, format: APIKey|Region": "Nhập khóa API, mỗi dòng một khóa, định dạng: APIKey|Region",
+    "Enter API Key, format: APIKey|Region": "Nhập khóa API, định dạng: APIKey|Region",
+    "Enter key, one per line, format: AccessKey|SecretAccessKey|Region": "Nhập khóa, mỗi dòng một khóa, định dạng: AccessKey|SecretAccessKey|Region",
+    "Enter key, format: AccessKey|SecretAccessKey|Region": "Nhập khóa, định dạng: AccessKey|SecretAccessKey|Region",
+    "Service account JSON file(s)": "Tệp JSON tài khoản dịch vụ",
+    "Please upload key file(s)": "Vui lòng tải lên (các) tệp khóa",
+    "Failed to parse JSON file: {{name}}": "Không thể phân tích cú pháp tệp JSON: {{name}}",
+    "Parsed {{count}} service account file(s)": "Đã phân tích {{count}} tệp tài khoản dịch vụ",
+    "Upload multiple JSON files in batch modes": "Tải lên nhiều tệp JSON trong chế độ hàng loạt",
+    "Upload a single service account JSON file": "Tải lên một tệp JSON tài khoản dịch vụ"
   }
 }

+ 119 - 1
web/src/i18n/locales/zh.json

@@ -295,6 +295,23 @@
     "Change Password": "更改密码",
     "Change To": "更改为",
     "Change language": "更改语言",
+    "Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
+    "Check in now": "立即签到",
+    "Check-in Rules": "签到规则",
+    "Check-in Settings": "签到设置",
+    "Check-in failed": "签到失败",
+    "Check-in successful! Received": "签到成功!获得",
+    "Checked in today": "今日已签到",
+    "Checked in today, total check-ins": "今日已签到,累计签到",
+    "Checking connection...": "检查连接中...",
+    "Configure daily check-in rewards for users": "配置用户每日签到奖励",
+    "Enable check-in feature": "启用签到功能",
+    "Allow users to check in daily for random quota rewards": "允许用户每日签到获取随机额度奖励",
+    "Minimum check-in quota": "签到最小额度",
+    "Maximum check-in quota": "签到最大额度",
+    "Minimum quota amount awarded for check-in": "签到奖励的最小额度",
+    "Maximum quota amount awarded for check-in": "签到奖励的最大额度",
+    "Save check-in settings": "保存签到设置",
     "Channel": "渠道",
     "Channel Extra Settings": "渠道额外设置",
     "Channel ID": "渠道 ID",
@@ -524,6 +541,7 @@
     "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "当特定用户分组使用特定令牌分组时的自定义乘数。示例:VIP 用户在使用“edit_this”分组令牌时获得 0.9 倍费率。",
     "Dark": "深色",
     "Dashboard": "数据看板",
+    "Daily Check-in": "每日签到",
     "Data Dashboard": "数据仪表板",
     "Data directory:": "数据目录:",
     "Data is stored locally on this device. Use system backups to keep a safe copy.": "数据存储在此设备本地。请使用系统备份来保留安全副本。",
@@ -531,6 +549,7 @@
     "Date and time when this announcement should be displayed": "此公告应显示的日期和时间",
     "DeepSeek": "DeepSeek",
     "Default": "默认",
+    "Deployments": "部署",
     "Default API Version *": "默认 API 版本 *",
     "Default API version for this channel": "此渠道的默认 API 版本",
     "Default Collapse Sidebar": "默认折叠侧边栏",
@@ -772,6 +791,7 @@
     "Failed": "失败",
     "Failed to apply overwrite.": "应用覆盖失败。",
     "Failed to bind email": "绑定邮箱失败",
+    "Failed to fetch checkin status": "获取签到状态失败",
     "Failed to change password": "修改密码失败",
     "Failed to clean logs": "清理日志失败",
     "Failed to complete Passkey login": "无法完成 Passkey 登录",
@@ -1100,6 +1120,7 @@
     "Make it easier for teammates to pick the right group.": "让队友更容易选择正确的分组。",
     "Manage": "管理",
     "Manage AI model metadata and vendor configurations": "管理 AI 模型元数据和供应商配置",
+    "Manage model metadata and deployments": "管理模型元信息与模型部署",
     "Manage API channels and provider configurations": "管理 API 渠道和提供商配置",
     "Manage Keys": "管理密钥",
     "Manage Vendors": "管理供应商",
@@ -1125,6 +1146,7 @@
     "Max Requests (including failures)": "最大请求数(包括失败)",
     "Max Success": "最大成功数",
     "Max Successful Requests": "最大成功请求数",
+    "Metadata": "元信息",
     "Max requests per period": "每周期最大请求数",
     "Max successful requests": "最大成功请求数",
     "Maximum 1000 characters. Supports Markdown and HTML.": "最多 1000 个字符。支持 Markdown 和 HTML。",
@@ -1566,6 +1588,8 @@
     "Quota:": "Quota:",
     "Random": "随机",
     "Randomly select a key from the pool for each request": "每次请求从池中随机选择一个密钥",
+    "Received": "获得",
+    "Rewards will be directly added to your account balance": "签到奖励将直接添加到您的账户余额",
     "Rate Limiting": "速率限制",
     "Ratio": "倍率",
     "Ratio Type": "比率类型",
@@ -1679,6 +1703,7 @@
     "Save Backup Codes": "保存备份代码",
     "Save Changes": "保存更改",
     "Save Creem settings": "保存 Creem 设置",
+    "Save io.net settings": "保存 io.net 设置",
     "Save Models": "保存模型",
     "Save SSRF settings": "保存 SSRF 设置",
     "Save Settings": "保存设置",
@@ -1712,6 +1737,8 @@
     "Secret Key": "密钥",
     "Secure & Reliable": "安全可靠",
     "Security": "安全",
+    "Security Check": "安全验证",
+    "Please complete the security check to continue.": "请完成安全验证以继续。",
     "Select": "选择",
     "Select Language": "选择语言",
     "Select Model": "选择模型",
@@ -1939,6 +1966,7 @@
     "This model has both fixed price and ratio billing conflicts": "此模型同时存在固定价格和比例计费冲突",
     "This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。",
     "This page has not been created yet.": "此页面尚未创建。",
+    "This month": "本月获得",
     "This project must be used in compliance with the": "此项目的使用必须遵守",
     "This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",
     "This will delete all": "这将删除所有",
@@ -1973,6 +2001,8 @@
     "Topup Amount": "充值金额",
     "Total Count": "总数",
     "Total Earned": "总收入",
+    "Total check-ins": "累计签到",
+    "Total earned": "累计获得",
     "Total Quota": "总额度",
     "Total Tokens": "总令牌数",
     "Total Usage": "总用量",
@@ -2169,6 +2199,7 @@
     "You are about to delete {{count}} API key(s).": "您即将删除 {{count}} 个 API 密钥。",
     "You can close this tab once the binding completes or a success message appears in the original window.": "绑定完成后或原窗口出现成功消息后,您可以关闭此标签页。",
     "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "你可以在\"自定义模型名称\"处手动添加它们,然后点击\"填入\"后再提交,或者直接使用下方操作自动处理。",
+    "You can only check in once per day": "每日仅可签到一次,请勿重复签到",
     "You don't have necessary permission": "您没有必要的权限",
     "You have unsaved changes": "您有未保存的更改",
     "You have unsaved changes. Are you sure you want to leave?": "您有未保存的更改。确定要离开吗?",
@@ -2335,6 +2366,93 @@
     "xAI": "xAI",
     "{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
     "| Based on": "| 基于",
-    "© 2025 Your Company. All rights reserved.": "© 2025 您的公司。保留所有权利。"
+    "© 2025 Your Company. All rights reserved.": "© 2025 您的公司。保留所有权利。",
+    "Model deployment service is disabled": "模型部署服务未启用",
+    "Configuration required": "需要配置",
+    "Please enable io.net model deployment service and configure an API key in System Settings.": "请先在系统设置中启用 io.net 模型部署服务,并配置 API Key。",
+    "Go to settings": "前往设置",
+    "Connection failed": "连接失败",
+    "Connection error": "连接错误",
+    "Deleted successfully": "删除成功",
+    "Delete failed": "删除失败",
+    "Time remaining": "剩余时间",
+    "Search deployments...": "搜索部署...",
+    "Create deployment": "创建部署",
+    "No data": "暂无数据",
+    "Total {{total}}": "共 {{total}} 条",
+    "Confirm delete": "确认删除",
+    "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "确定要删除部署 \"{{name}}\" 吗?此操作不可撤销。",
+    "Deployment logs": "部署日志",
+    "Deployment ID": "部署 ID",
+    "Download": "下载",
+    "No logs": "暂无日志",
+    "Deployment created successfully": "创建部署成功",
+    "Failed to create deployment": "创建部署失败",
+    "Container name": "容器名称",
+    "Hardware type": "硬件类型",
+    "Deployment location": "部署位置",
+    "Select locations": "选择位置",
+    "GPU count": "GPU数量",
+    "Replica count": "副本数",
+    "Duration (hours)": "时长(小时)",
+    "Price estimation": "价格预估",
+    "Calculating...": "计算中...",
+    "Not calculated": "未计算",
+    "Advanced configuration": "高级配置",
+    "Environment variables (JSON)": "环境变量(JSON)",
+    "Secret environment variables (JSON)": "密钥环境变量(JSON)",
+    "Submitting...": "提交中...",
+    "io.net Deployments": "io.net 部署",
+    "Configure io.net API key for model deployments": "配置 io.net API Key 用于模型部署",
+    "Enable io.net deployments": "启用 io.net 部署",
+    "Enable io.net model deployment service in console": "在控制台启用 io.net 模型部署服务",
+    "Enter API Key": "请输入 API Key",
+    "Used to authenticate with io.net deployment API": "用于 io.net 部署 API 鉴权",
+    "Connection successful": "连接成功",
+    "Connected to io.net service normally.": "已正常连接 io.net 服务。",
+    "Ollama Models": "Ollama 模型",
+    "Manage Ollama Models": "管理 Ollama 模型",
+    "Manage local models for:": "管理本地模型:",
+    "This channel is not an Ollama channel.": "该渠道不是 Ollama 渠道。",
+    "Pull model": "拉取模型",
+    "Pull": "拉取",
+    "Pulling...": "拉取中...",
+    "Status:": "状态:",
+    "Local models": "本地模型",
+    "Select models and apply to channel models list.": "选择模型并应用到渠道模型列表。",
+    "Select all (filtered)": "全选(筛选结果)",
+    "Append to channel": "追加到渠道",
+    "Replace channel models": "覆盖渠道模型",
+    "No models found.": "未找到模型。",
+    "Models appended successfully": "模型已追加成功",
+    "Please enter model name": "请输入模型名称",
+    "Please set Ollama API Base URL first": "请先设置 Ollama API Base URL",
+    "Model pull failed: {{msg}}": "模型拉取失败:{{msg}}",
+    "Model deleted": "模型已删除",
+    "AWS Key Format": "AWS 密钥格式",
+    "Select key format": "请选择密钥格式",
+    "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey",
+    "API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
+    "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region",
+    "Field passthrough controls": "字段透传控制",
+    "These toggles affect whether certain request fields are passed through to the upstream provider.": "这些开关控制某些请求字段是否透传到上游服务。",
+    "Allow service_tier passthrough": "允许透传 service_tier",
+    "Pass through the service_tier field": "将 service_tier 字段透传到上游",
+    "Disable store passthrough": "禁止透传 store",
+    "When enabled, the store field will be blocked": "开启后将阻止 store 字段透传",
+    "Allow safety_identifier passthrough": "允许透传 safety_identifier",
+    "Pass through the safety_identifier field": "将 safety_identifier 字段透传到上游",
+    "From IO.NET deployment": "来自 IO.NET 部署",
+    "Click to open deployment": "点击打开部署",
+    "Enter API Key, one per line, format: APIKey|Region": "请输入 API Key(每行一个),格式:APIKey|Region",
+    "Enter API Key, format: APIKey|Region": "请输入 API Key,格式:APIKey|Region",
+    "Enter key, one per line, format: AccessKey|SecretAccessKey|Region": "请输入密钥(每行一个),格式:AccessKey|SecretAccessKey|Region",
+    "Enter key, format: AccessKey|SecretAccessKey|Region": "请输入密钥,格式:AccessKey|SecretAccessKey|Region",
+    "Service account JSON file(s)": "服务账号 JSON 文件",
+    "Please upload key file(s)": "请上传密钥文件",
+    "Failed to parse JSON file: {{name}}": "解析 JSON 文件失败:{{name}}",
+    "Parsed {{count}} service account file(s)": "已解析 {{count}} 个服务账号文件",
+    "Upload multiple JSON files in batch modes": "批量模式下可上传多个 JSON 文件",
+    "Upload a single service account JSON file": "上传单个服务账号 JSON 文件"
   }
 }

+ 6 - 0
web/src/routes/_authenticated/models/index.tsx

@@ -5,12 +5,18 @@ import { ROLE } from '@/lib/roles'
 import { Models } from '@/features/models'
 
 const modelsSearchSchema = z.object({
+  tab: z.enum(['metadata', 'deployments']).optional(),
   page: z.number().optional().catch(1),
   pageSize: z.number().optional().catch(10),
   filter: z.string().optional().catch(''),
   vendor: z.array(z.string()).optional().catch([]),
   status: z.array(z.string()).optional().catch([]),
   sync: z.array(z.string()).optional().catch([]),
+  // Deployments tab (use dedicated keys to avoid clashing with metadata table)
+  dPage: z.number().optional().catch(1),
+  dPageSize: z.number().optional().catch(10),
+  dFilter: z.string().optional().catch(''),
+  dStatus: z.array(z.string()).optional().catch([]),
 })
 
 export const Route = createFileRoute('/_authenticated/models/')({