Copilot commented on code in PR #7505:
URL: https://github.com/apache/kyuubi/pull/7505#discussion_r3378012340


##########
kyuubi-server/web-ui/src/utils/use-table.ts:
##########
@@ -16,6 +16,7 @@
  */
 
 import { ref, Ref } from 'vue'
+import { ElMessage } from 'element-plus'
 

Review Comment:
   `useTable` now imports Element Plus and shows an `ElMessage` toast with a 
hard-coded English fallback ('Request failed'). This couples a generic 
composable to UI messaging and bypasses i18n; it can also cause unexpected 
global toasts anywhere `useTable()` is used. Prefer keeping `useTable` 
UI-agnostic and letting callers decide how to surface errors.



##########
kyuubi-server/web-ui/src/views/management/batch/index.vue:
##########
@@ -0,0 +1,647 @@
+<!--
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+-->
+<template>
+  <el-card :body-style="{ padding: '10px 14px' }" class="filter-card">
+    <header>
+      <el-space class="search-box" wrap>
+        <el-input
+          v-model="searchParam.batchUser"
+          :placeholder="$t('user')"
+          clearable
+          style="width: 180px"
+          @keyup.enter="searchBatches" />
+        <el-input
+          v-model="searchParam.batchName"
+          :placeholder="$t('batch_name')"
+          clearable
+          style="width: 180px"
+          @keyup.enter="searchBatches" />
+        <el-select
+          v-model="searchParam.batchType"
+          :placeholder="$t('batch_type')"
+          clearable
+          style="width: 150px"
+          @change="searchBatches">
+          <el-option label="SPARK" value="SPARK" />
+          <el-option label="PYSPARK" value="PYSPARK" />
+        </el-select>
+        <el-select
+          v-model="searchParam.batchState"
+          :placeholder="$t('state')"
+          clearable
+          style="width: 150px"
+          @change="searchBatches">
+          <el-option
+            v-for="state in batchStates"
+            :key="state"
+            :label="state"
+            :value="state" />
+        </el-select>
+        <el-button type="primary" icon="Search" @click="searchBatches" />
+        <el-button icon="Refresh" @click="getList" />
+      </el-space>
+    </header>
+  </el-card>
+
+  <el-card class="table-container">
+    <el-table
+      v-loading="loading"
+      class="batch-table"
+      :data="tableData"
+      style="width: 100%">
+      <el-table-column prop="user" :label="$t('user')" min-width="120" />
+      <el-table-column :label="$t('batch_id')" min-width="300">
+        <template #default="scope">
+          <span class="truncate-text">{{ scope.row.id }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t('batch_name')" min-width="180">
+        <template #default="scope">{{ display(scope.row.name) }}</template>
+      </el-table-column>
+      <el-table-column
+        prop="batchType"
+        :label="$t('batch_type')"
+        min-width="130" />
+      <el-table-column prop="state" :label="$t('state')" min-width="120" />
+      <el-table-column :label="$t('app_state')" min-width="170">
+        <template #default="scope">{{ display(scope.row.appState) }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('app_id')" min-width="210">
+        <template #default="scope">{{ display(scope.row.appId) }}</template>
+      </el-table-column>
+      <el-table-column
+        prop="kyuubiInstance"
+        :label="$t('kyuubi_instance')"
+        min-width="220"
+        show-overflow-tooltip />
+      <el-table-column :label="$t('create_time')" min-width="170">
+        <template #default="scope">{{
+          formatTime(scope.row.createTime)
+        }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('end_time')" min-width="170">
+        <template #default="scope">{{
+          formatTime(scope.row.endTime)
+        }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('duration')" min-width="130">
+        <template #default="scope">{{ formatDuration(scope.row) }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('app_diagnostic')" min-width="240">
+        <template #default="scope">
+          <el-tooltip
+            v-if="scope.row.appDiagnostic"
+            effect="dark"
+            :content="scope.row.appDiagnostic"
+            placement="top">
+            <span class="truncate-text">{{ scope.row.appDiagnostic }}</span>
+          </el-tooltip>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column fixed="right" :label="$t('operation.text')" width="160">
+        <template #default="scope">
+          <el-space wrap>
+            <el-tooltip
+              v-if="canShowAppUI(scope.row)"
+              effect="dark"
+              :content="$t('app_url') + ': ' + getAppUI(scope.row.appUrl)"
+              placement="top">
+              <el-button
+                type="primary"
+                icon="Link"
+                circle
+                @click="openAppUI(scope.row.appUrl)" />
+            </el-tooltip>
+            <el-tooltip effect="dark" :content="$t('log')" placement="top">
+              <el-button icon="Document" circle @click="openLog(scope.row)" />
+            </el-tooltip>
+            <el-popconfirm
+              v-if="!isTerminalState(scope.row.state)"
+              :title="$t('operation.cancel_confirm')"
+              @confirm="handleDeleteBatch(scope.row)">
+              <template #reference>
+                <span>
+                  <el-tooltip
+                    effect="dark"
+                    :content="$t('operation.cancel')"
+                    placement="top">
+                    <template #default>
+                      <el-button type="danger" icon="Remove" circle />
+                    </template>
+                  </el-tooltip>
+                </span>
+              </template>
+            </el-popconfirm>
+          </el-space>
+        </template>
+      </el-table-column>
+    </el-table>
+    <footer class="pager">
+      <span>{{ pageRangeText }}</span>
+      <el-space>
+        <el-button
+          icon="ArrowLeft"
+          :disabled="!hasPreviousPage || loading"
+          @click="previousPage">
+          {{ $t('previous') }}
+        </el-button>
+        <el-button
+          icon="ArrowRight"
+          :disabled="!hasNextPage || loading"
+          @click="nextPage">
+          {{ $t('next') }}
+        </el-button>
+      </el-space>
+    </footer>
+  </el-card>
+
+  <el-drawer
+    v-model="logDrawerVisible"
+    :title="selectedBatch ? `${$t('log')} - ${selectedBatch.id}` : $t('log')"
+    size="60%"
+    @close="onLogDrawerClose">
+    <div class="log-body">
+      <div class="log-toolbar">
+        <el-switch
+          v-model="logFollowing"
+          :disabled="logFollowDisabled"
+          :active-text="$t('follow')"
+          @change="onFollowChange" />
+        <el-button
+          icon="Refresh"
+          :disabled="logFollowing || logLoading"
+          @click="refreshLog">
+          {{ $t('refresh') }}
+        </el-button>
+        <span v-if="logTruncated" class="log-truncated">
+          {{ $t('log_truncated', { count: LOG_MAX_ROWS }) }}
+        </span>
+      </div>
+      <el-scrollbar
+        ref="logScrollbarRef"
+        v-loading="logLoading"
+        class="log-container">
+        <pre v-if="batchLogs.length > 0">{{ batchLogs.join('\n') }}</pre>
+        <el-empty v-else :description="$t('no_log')" />
+      </el-scrollbar>
+    </div>
+  </el-drawer>
+</template>
+
+<script lang="ts" setup>
+  import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
+  import { format } from 'date-fns'
+  import { ElMessage } from 'element-plus'
+  import { useI18n } from 'vue-i18n'
+  import {
+    deleteBatch,
+    getAllBatches,
+    getBatch,
+    getBatchLocalLog
+  } from '@/api/batch'
+  import { IBatch, IBatchSearch } from '@/api/batch/types'
+  import { getWebUIConfig } from '@/api/server'
+  import { IWebUIConfig } from '@/api/server/types'
+  import { getEngineUIUrl } from '@/utils/engine-ui'
+  import { millTransfer } from '@/utils/unit'
+
+  const { t } = useI18n()
+  const loading = ref(false)
+  const tableData = ref<IBatch[]>([])
+  const pageSize = ref(100)
+  const logDrawerVisible = ref(false)
+  const logLoading = ref(false)

Review Comment:
   Server-side pagination exposes `total` in the `/api/v1/batches` response, 
but the view doesn’t track it. Without keeping `total`, the pager can’t 
reliably know whether more pages exist (e.g., when `total` is an exact multiple 
of `pageSize`). Store `total` alongside `tableData` so the Next button can be 
computed correctly.



##########
kyuubi-server/web-ui/src/utils/use-table.ts:
##########
@@ -56,7 +57,16 @@ export function useTable() {
     loading.value = true
     func(data)
       .then((res: any[]) => (list.value = res || []))
-      .catch(() => (list.value = []))
+      .catch((error: unknown) => {
+        list.value = []
+        ElMessage({
+          message:
+            error instanceof Error && error.message
+              ? error.message
+              : 'Request failed',
+          type: 'error'
+        })
+      })
       .finally(() => {

Review Comment:
   The error handler in `useTable.getList` now always shows an `ElMessage` 
toast. This changes behavior app-wide and uses a non-localized fallback 
message. If you want error toasts, it’s better to handle them in the calling 
view where you have i18n context and can tailor the message.



##########
kyuubi-server/web-ui/src/views/management/batch/index.vue:
##########
@@ -0,0 +1,647 @@
+<!--
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+-->
+<template>
+  <el-card :body-style="{ padding: '10px 14px' }" class="filter-card">
+    <header>
+      <el-space class="search-box" wrap>
+        <el-input
+          v-model="searchParam.batchUser"
+          :placeholder="$t('user')"
+          clearable
+          style="width: 180px"
+          @keyup.enter="searchBatches" />
+        <el-input
+          v-model="searchParam.batchName"
+          :placeholder="$t('batch_name')"
+          clearable
+          style="width: 180px"
+          @keyup.enter="searchBatches" />
+        <el-select
+          v-model="searchParam.batchType"
+          :placeholder="$t('batch_type')"
+          clearable
+          style="width: 150px"
+          @change="searchBatches">
+          <el-option label="SPARK" value="SPARK" />
+          <el-option label="PYSPARK" value="PYSPARK" />
+        </el-select>
+        <el-select
+          v-model="searchParam.batchState"
+          :placeholder="$t('state')"
+          clearable
+          style="width: 150px"
+          @change="searchBatches">
+          <el-option
+            v-for="state in batchStates"
+            :key="state"
+            :label="state"
+            :value="state" />
+        </el-select>
+        <el-button type="primary" icon="Search" @click="searchBatches" />
+        <el-button icon="Refresh" @click="getList" />
+      </el-space>
+    </header>
+  </el-card>
+
+  <el-card class="table-container">
+    <el-table
+      v-loading="loading"
+      class="batch-table"
+      :data="tableData"
+      style="width: 100%">
+      <el-table-column prop="user" :label="$t('user')" min-width="120" />
+      <el-table-column :label="$t('batch_id')" min-width="300">
+        <template #default="scope">
+          <span class="truncate-text">{{ scope.row.id }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t('batch_name')" min-width="180">
+        <template #default="scope">{{ display(scope.row.name) }}</template>
+      </el-table-column>
+      <el-table-column
+        prop="batchType"
+        :label="$t('batch_type')"
+        min-width="130" />
+      <el-table-column prop="state" :label="$t('state')" min-width="120" />
+      <el-table-column :label="$t('app_state')" min-width="170">
+        <template #default="scope">{{ display(scope.row.appState) }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('app_id')" min-width="210">
+        <template #default="scope">{{ display(scope.row.appId) }}</template>
+      </el-table-column>
+      <el-table-column
+        prop="kyuubiInstance"
+        :label="$t('kyuubi_instance')"
+        min-width="220"
+        show-overflow-tooltip />
+      <el-table-column :label="$t('create_time')" min-width="170">
+        <template #default="scope">{{
+          formatTime(scope.row.createTime)
+        }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('end_time')" min-width="170">
+        <template #default="scope">{{
+          formatTime(scope.row.endTime)
+        }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('duration')" min-width="130">
+        <template #default="scope">{{ formatDuration(scope.row) }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('app_diagnostic')" min-width="240">
+        <template #default="scope">
+          <el-tooltip
+            v-if="scope.row.appDiagnostic"
+            effect="dark"
+            :content="scope.row.appDiagnostic"
+            placement="top">
+            <span class="truncate-text">{{ scope.row.appDiagnostic }}</span>
+          </el-tooltip>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column fixed="right" :label="$t('operation.text')" width="160">
+        <template #default="scope">
+          <el-space wrap>
+            <el-tooltip
+              v-if="canShowAppUI(scope.row)"
+              effect="dark"
+              :content="$t('app_url') + ': ' + getAppUI(scope.row.appUrl)"
+              placement="top">
+              <el-button
+                type="primary"
+                icon="Link"
+                circle
+                @click="openAppUI(scope.row.appUrl)" />
+            </el-tooltip>
+            <el-tooltip effect="dark" :content="$t('log')" placement="top">
+              <el-button icon="Document" circle @click="openLog(scope.row)" />
+            </el-tooltip>
+            <el-popconfirm
+              v-if="!isTerminalState(scope.row.state)"
+              :title="$t('operation.cancel_confirm')"
+              @confirm="handleDeleteBatch(scope.row)">
+              <template #reference>
+                <span>
+                  <el-tooltip
+                    effect="dark"
+                    :content="$t('operation.cancel')"
+                    placement="top">
+                    <template #default>
+                      <el-button type="danger" icon="Remove" circle />
+                    </template>
+                  </el-tooltip>
+                </span>
+              </template>
+            </el-popconfirm>
+          </el-space>
+        </template>
+      </el-table-column>
+    </el-table>
+    <footer class="pager">
+      <span>{{ pageRangeText }}</span>
+      <el-space>
+        <el-button
+          icon="ArrowLeft"
+          :disabled="!hasPreviousPage || loading"
+          @click="previousPage">
+          {{ $t('previous') }}
+        </el-button>
+        <el-button
+          icon="ArrowRight"
+          :disabled="!hasNextPage || loading"
+          @click="nextPage">
+          {{ $t('next') }}
+        </el-button>
+      </el-space>
+    </footer>
+  </el-card>
+
+  <el-drawer
+    v-model="logDrawerVisible"
+    :title="selectedBatch ? `${$t('log')} - ${selectedBatch.id}` : $t('log')"
+    size="60%"
+    @close="onLogDrawerClose">
+    <div class="log-body">
+      <div class="log-toolbar">
+        <el-switch
+          v-model="logFollowing"
+          :disabled="logFollowDisabled"
+          :active-text="$t('follow')"
+          @change="onFollowChange" />
+        <el-button
+          icon="Refresh"
+          :disabled="logFollowing || logLoading"
+          @click="refreshLog">
+          {{ $t('refresh') }}
+        </el-button>
+        <span v-if="logTruncated" class="log-truncated">
+          {{ $t('log_truncated', { count: LOG_MAX_ROWS }) }}
+        </span>
+      </div>
+      <el-scrollbar
+        ref="logScrollbarRef"
+        v-loading="logLoading"
+        class="log-container">
+        <pre v-if="batchLogs.length > 0">{{ batchLogs.join('\n') }}</pre>
+        <el-empty v-else :description="$t('no_log')" />
+      </el-scrollbar>
+    </div>
+  </el-drawer>
+</template>
+
+<script lang="ts" setup>
+  import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
+  import { format } from 'date-fns'
+  import { ElMessage } from 'element-plus'
+  import { useI18n } from 'vue-i18n'
+  import {
+    deleteBatch,
+    getAllBatches,
+    getBatch,
+    getBatchLocalLog
+  } from '@/api/batch'
+  import { IBatch, IBatchSearch } from '@/api/batch/types'
+  import { getWebUIConfig } from '@/api/server'
+  import { IWebUIConfig } from '@/api/server/types'
+  import { getEngineUIUrl } from '@/utils/engine-ui'
+  import { millTransfer } from '@/utils/unit'
+
+  const { t } = useI18n()
+  const loading = ref(false)
+  const tableData = ref<IBatch[]>([])
+  const pageSize = ref(100)
+  const logDrawerVisible = ref(false)
+  const logLoading = ref(false)
+  const batchLogs = ref<string[]>([])
+  const selectedBatch = ref<IBatch>()
+  const logFollowing = ref(false)
+  const logTruncated = ref(false)
+  const logScrollbarRef = ref()
+  const logNextFrom = ref(0)
+  let logTimer: ReturnType<typeof setTimeout> | undefined
+  // Bumped whenever the log view is reset (open / refresh / close) so 
in-flight
+  // fetches from a previous view discard their results instead of appending.
+  let logEpoch = 0
+
+  const batchStates = ['PENDING', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELED']
+  const terminalBatchStates = new Set(['FINISHED', 'ERROR', 'CANCELED'])
+
+  // Batch log viewer: fetch the operation log by absolute line offset (server
+  // OperationLog.read(from, size) treats `from` as a line index), tracking the
+  // cursor on the client so a live tail never fights the shared server cursor.
+  const LOG_FETCH_SIZE = 1000
+  const LOG_POLL_INTERVAL_MS = 2000
+  const LOG_MAX_ROWS = 5000
+
+  const searchParam: IBatchSearch = reactive({
+    from: 0,
+    size: 100,
+    desc: true
+  })
+
+  const hasPreviousPage = computed(() => (searchParam.from || 0) > 0)
+  const hasNextPage = computed(() => tableData.value.length === pageSize.value)
+  const pageRangeText = computed(() => {

Review Comment:
   `hasNextPage` is currently inferred from `tableData.length === pageSize`, 
which can enable Next even when the current page is the last page (when `total` 
is an exact multiple of `pageSize`). Compute Next availability using `from + 
tableData.length < total` instead.



##########
kyuubi-server/web-ui/src/views/management/batch/index.vue:
##########
@@ -0,0 +1,647 @@
+<!--
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+-->
+<template>
+  <el-card :body-style="{ padding: '10px 14px' }" class="filter-card">
+    <header>
+      <el-space class="search-box" wrap>
+        <el-input
+          v-model="searchParam.batchUser"
+          :placeholder="$t('user')"
+          clearable
+          style="width: 180px"
+          @keyup.enter="searchBatches" />
+        <el-input
+          v-model="searchParam.batchName"
+          :placeholder="$t('batch_name')"
+          clearable
+          style="width: 180px"
+          @keyup.enter="searchBatches" />
+        <el-select
+          v-model="searchParam.batchType"
+          :placeholder="$t('batch_type')"
+          clearable
+          style="width: 150px"
+          @change="searchBatches">
+          <el-option label="SPARK" value="SPARK" />
+          <el-option label="PYSPARK" value="PYSPARK" />
+        </el-select>
+        <el-select
+          v-model="searchParam.batchState"
+          :placeholder="$t('state')"
+          clearable
+          style="width: 150px"
+          @change="searchBatches">
+          <el-option
+            v-for="state in batchStates"
+            :key="state"
+            :label="state"
+            :value="state" />
+        </el-select>
+        <el-button type="primary" icon="Search" @click="searchBatches" />
+        <el-button icon="Refresh" @click="getList" />
+      </el-space>
+    </header>
+  </el-card>
+
+  <el-card class="table-container">
+    <el-table
+      v-loading="loading"
+      class="batch-table"
+      :data="tableData"
+      style="width: 100%">
+      <el-table-column prop="user" :label="$t('user')" min-width="120" />
+      <el-table-column :label="$t('batch_id')" min-width="300">
+        <template #default="scope">
+          <span class="truncate-text">{{ scope.row.id }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t('batch_name')" min-width="180">
+        <template #default="scope">{{ display(scope.row.name) }}</template>
+      </el-table-column>
+      <el-table-column
+        prop="batchType"
+        :label="$t('batch_type')"
+        min-width="130" />
+      <el-table-column prop="state" :label="$t('state')" min-width="120" />
+      <el-table-column :label="$t('app_state')" min-width="170">
+        <template #default="scope">{{ display(scope.row.appState) }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('app_id')" min-width="210">
+        <template #default="scope">{{ display(scope.row.appId) }}</template>
+      </el-table-column>
+      <el-table-column
+        prop="kyuubiInstance"
+        :label="$t('kyuubi_instance')"
+        min-width="220"
+        show-overflow-tooltip />
+      <el-table-column :label="$t('create_time')" min-width="170">
+        <template #default="scope">{{
+          formatTime(scope.row.createTime)
+        }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('end_time')" min-width="170">
+        <template #default="scope">{{
+          formatTime(scope.row.endTime)
+        }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('duration')" min-width="130">
+        <template #default="scope">{{ formatDuration(scope.row) }}</template>
+      </el-table-column>
+      <el-table-column :label="$t('app_diagnostic')" min-width="240">
+        <template #default="scope">
+          <el-tooltip
+            v-if="scope.row.appDiagnostic"
+            effect="dark"
+            :content="scope.row.appDiagnostic"
+            placement="top">
+            <span class="truncate-text">{{ scope.row.appDiagnostic }}</span>
+          </el-tooltip>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column fixed="right" :label="$t('operation.text')" width="160">
+        <template #default="scope">
+          <el-space wrap>
+            <el-tooltip
+              v-if="canShowAppUI(scope.row)"
+              effect="dark"
+              :content="$t('app_url') + ': ' + getAppUI(scope.row.appUrl)"
+              placement="top">
+              <el-button
+                type="primary"
+                icon="Link"
+                circle
+                @click="openAppUI(scope.row.appUrl)" />
+            </el-tooltip>
+            <el-tooltip effect="dark" :content="$t('log')" placement="top">
+              <el-button icon="Document" circle @click="openLog(scope.row)" />
+            </el-tooltip>
+            <el-popconfirm
+              v-if="!isTerminalState(scope.row.state)"
+              :title="$t('operation.cancel_confirm')"
+              @confirm="handleDeleteBatch(scope.row)">
+              <template #reference>
+                <span>
+                  <el-tooltip
+                    effect="dark"
+                    :content="$t('operation.cancel')"
+                    placement="top">
+                    <template #default>
+                      <el-button type="danger" icon="Remove" circle />
+                    </template>
+                  </el-tooltip>
+                </span>
+              </template>
+            </el-popconfirm>
+          </el-space>
+        </template>
+      </el-table-column>
+    </el-table>
+    <footer class="pager">
+      <span>{{ pageRangeText }}</span>
+      <el-space>
+        <el-button
+          icon="ArrowLeft"
+          :disabled="!hasPreviousPage || loading"
+          @click="previousPage">
+          {{ $t('previous') }}
+        </el-button>
+        <el-button
+          icon="ArrowRight"
+          :disabled="!hasNextPage || loading"
+          @click="nextPage">
+          {{ $t('next') }}
+        </el-button>
+      </el-space>
+    </footer>
+  </el-card>
+
+  <el-drawer
+    v-model="logDrawerVisible"
+    :title="selectedBatch ? `${$t('log')} - ${selectedBatch.id}` : $t('log')"
+    size="60%"
+    @close="onLogDrawerClose">
+    <div class="log-body">
+      <div class="log-toolbar">
+        <el-switch
+          v-model="logFollowing"
+          :disabled="logFollowDisabled"
+          :active-text="$t('follow')"
+          @change="onFollowChange" />
+        <el-button
+          icon="Refresh"
+          :disabled="logFollowing || logLoading"
+          @click="refreshLog">
+          {{ $t('refresh') }}
+        </el-button>
+        <span v-if="logTruncated" class="log-truncated">
+          {{ $t('log_truncated', { count: LOG_MAX_ROWS }) }}
+        </span>
+      </div>
+      <el-scrollbar
+        ref="logScrollbarRef"
+        v-loading="logLoading"
+        class="log-container">
+        <pre v-if="batchLogs.length > 0">{{ batchLogs.join('\n') }}</pre>
+        <el-empty v-else :description="$t('no_log')" />
+      </el-scrollbar>
+    </div>
+  </el-drawer>
+</template>
+
+<script lang="ts" setup>
+  import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'
+  import { format } from 'date-fns'
+  import { ElMessage } from 'element-plus'
+  import { useI18n } from 'vue-i18n'
+  import {
+    deleteBatch,
+    getAllBatches,
+    getBatch,
+    getBatchLocalLog
+  } from '@/api/batch'
+  import { IBatch, IBatchSearch } from '@/api/batch/types'
+  import { getWebUIConfig } from '@/api/server'
+  import { IWebUIConfig } from '@/api/server/types'
+  import { getEngineUIUrl } from '@/utils/engine-ui'
+  import { millTransfer } from '@/utils/unit'
+
+  const { t } = useI18n()
+  const loading = ref(false)
+  const tableData = ref<IBatch[]>([])
+  const pageSize = ref(100)
+  const logDrawerVisible = ref(false)
+  const logLoading = ref(false)
+  const batchLogs = ref<string[]>([])
+  const selectedBatch = ref<IBatch>()
+  const logFollowing = ref(false)
+  const logTruncated = ref(false)
+  const logScrollbarRef = ref()
+  const logNextFrom = ref(0)
+  let logTimer: ReturnType<typeof setTimeout> | undefined
+  // Bumped whenever the log view is reset (open / refresh / close) so 
in-flight
+  // fetches from a previous view discard their results instead of appending.
+  let logEpoch = 0
+
+  const batchStates = ['PENDING', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELED']
+  const terminalBatchStates = new Set(['FINISHED', 'ERROR', 'CANCELED'])
+
+  // Batch log viewer: fetch the operation log by absolute line offset (server
+  // OperationLog.read(from, size) treats `from` as a line index), tracking the
+  // cursor on the client so a live tail never fights the shared server cursor.
+  const LOG_FETCH_SIZE = 1000
+  const LOG_POLL_INTERVAL_MS = 2000
+  const LOG_MAX_ROWS = 5000
+
+  const searchParam: IBatchSearch = reactive({
+    from: 0,
+    size: 100,
+    desc: true
+  })
+
+  const hasPreviousPage = computed(() => (searchParam.from || 0) > 0)
+  const hasNextPage = computed(() => tableData.value.length === pageSize.value)
+  const pageRangeText = computed(() => {
+    const from = searchParam.from || 0
+    if (tableData.value.length === 0) {
+      return '0 - 0'
+    }
+    return `${from + 1} - ${from + tableData.value.length}`
+  })
+
+  const engineUIProxyConfig: IWebUIConfig = reactive({
+    engineUIProxyEnabled: false
+  })
+
+  function display(value: string | null | undefined): string {
+    return value ? value : '-'
+  }
+
+  function formatTime(value: number): string {
+    return value != null && value > 0
+      ? format(value, 'yyyy-MM-dd HH:mm:ss')
+      : '-'
+  }
+
+  function formatDuration(batch: IBatch): string {
+    return batch.createTime > 0 && batch.endTime > 0
+      ? millTransfer(batch.endTime - batch.createTime)
+      : '-'
+  }
+
+  function isTerminalState(state: string): boolean {
+    return terminalBatchStates.has(state)
+  }
+
+  // The driver UI is only reachable while the engine is alive; once the batch 
is
+  // terminal the app/driver is gone, so hide the link rather than open a 502.
+  function canShowAppUI(batch: IBatch): boolean {
+    return Boolean(batch.appUrl) && !isTerminalState(batch.state)
+  }
+
+  function getErrorMessage(error: unknown, fallback: string): string {
+    if (error instanceof Error && error.message) {
+      return error.message
+    }
+    return fallback
+  }
+
+  function getAppUI(url: string): string {
+    return getEngineUIUrl(url, engineUIProxyConfig.engineUIProxyEnabled)
+  }
+
+  function openAppUI(url: string) {
+    window.open(getAppUI(url))
+  }
+
+  function getList() {
+    loading.value = true
+    searchParam.size = pageSize.value
+    getAllBatches(searchParam)
+      .then((res) => {
+        tableData.value = res.batches || []
+      })
+      .catch((error) => {
+        tableData.value = []
+        ElMessage({
+          message: getErrorMessage(error, t('message.get_batches_failed')),
+          type: 'error'
+        })
+      })

Review Comment:
   `getList()` ignores the `total` returned by `/api/v1/batches`, so pagination 
state can’t be computed accurately. Capture `res.total` (and reset it on error) 
so the pager reflects the real last page.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to