|
|
@@ -17,12 +17,7 @@
|
|
|
<div class="filter-grid">
|
|
|
<div class="filter-item">
|
|
|
<label>状态</label>
|
|
|
- <select v-model="filters.status">
|
|
|
- <option value="">全部</option>
|
|
|
- <option value="IDLE">空闲</option>
|
|
|
- <option value="WORKING">工作中</option>
|
|
|
- <option value="OFFLINE">离线</option>
|
|
|
- </select>
|
|
|
+ <UiSelect v-model="filters.status" :options="stockFilterStatusOptions" placeholder="全部" />
|
|
|
</div>
|
|
|
<div class="filter-item">
|
|
|
<label>IP 地址</label>
|
|
|
@@ -50,7 +45,7 @@
|
|
|
<th>所属集群ID</th>
|
|
|
<th>状态</th>
|
|
|
<th class="col-remark">备注</th>
|
|
|
- <th>操作</th>
|
|
|
+ <th class="action-column">操作</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
@@ -61,7 +56,7 @@
|
|
|
<td colspan="10" class="empty-row">暂无数据</td>
|
|
|
</tr>
|
|
|
<tr v-for="item in table.list" :key="item.machineId">
|
|
|
- <td class="id-text">{{ item.machineId }}</td>
|
|
|
+ <td class="id-text">{{ formatDisplayId(item.machineId) }}</td>
|
|
|
<td class="ip-text">{{ item.ipAddress }}</td>
|
|
|
<td>{{ item.cpuCores }}</td>
|
|
|
<td>{{ item.cpuModel }}</td>
|
|
|
@@ -73,7 +68,7 @@
|
|
|
class="cluster-link-btn"
|
|
|
@click="goToClusterConsole(clusterIdByMachineId[item.machineId])"
|
|
|
>
|
|
|
- {{ clusterIdByMachineId[item.machineId] }}
|
|
|
+ {{ formatDisplayId(clusterIdByMachineId[item.machineId]) }}
|
|
|
</button>
|
|
|
<span v-else>-</span>
|
|
|
</td>
|
|
|
@@ -92,8 +87,8 @@
|
|
|
>编辑</button>
|
|
|
<button
|
|
|
class="btn btn-danger btn-sm"
|
|
|
- :disabled="item.status === 'WORKING'"
|
|
|
- :title="item.status === 'WORKING' ? '工作中状态不可删除' : ''"
|
|
|
+ :disabled="item.status === 'WORKING' || item.status === 'JOINING'"
|
|
|
+ :title="item.status === 'WORKING' || item.status === 'JOINING' ? '工作中或加入中状态不可删除' : ''"
|
|
|
@click="handleDelete(item)"
|
|
|
>删除</button>
|
|
|
</td>
|
|
|
@@ -103,9 +98,7 @@
|
|
|
</div>
|
|
|
<div class="pagination">
|
|
|
<label>每页
|
|
|
- <select v-model.number="table.pageSize" @change="search">
|
|
|
- <option v-for="n in [10, 20, 50]" :key="n" :value="n">{{ n }}</option>
|
|
|
- </select>
|
|
|
+ <UiSelect v-model="table.pageSize" compact class="page-size-select" :options="pageSizeOptions" @change="search" />
|
|
|
条
|
|
|
</label>
|
|
|
<div class="pagination-right">
|
|
|
@@ -130,24 +123,21 @@
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
<label>SSH 用户名 <span class="required">*</span></label>
|
|
|
- <input v-model.trim="form.sshUsername" type="text" placeholder="例如 root / ubuntu" autocomplete="off" :disabled="isWorkingEdit" />
|
|
|
+ <input v-model.trim="form.sshUsername" type="text" placeholder="例如 root / ubuntu" autocomplete="off" :disabled="isManagedLifecycleEdit" />
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
<label>
|
|
|
SSH 密码<span v-if="dialog.isEdit">(留空不修改)</span><span v-else class="required">*</span>
|
|
|
</label>
|
|
|
- <input v-model.trim="form.sshPassword" type="password" placeholder="输入 SSH 密码" autocomplete="new-password" :disabled="isWorkingEdit" />
|
|
|
+ <input v-model.trim="form.sshPassword" type="password" placeholder="输入 SSH 密码" autocomplete="new-password" :disabled="isManagedLifecycleEdit" />
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
<label>备注</label>
|
|
|
<input v-model.trim="form.description" type="text" placeholder="例如 rack-a-01" maxlength="50" />
|
|
|
</div>
|
|
|
- <div v-if="dialog.isEdit && !isWorkingEdit" class="form-field">
|
|
|
+ <div v-if="dialog.isEdit && !isManagedLifecycleEdit" class="form-field">
|
|
|
<label>状态</label>
|
|
|
- <select v-model="form.status">
|
|
|
- <option value="IDLE">空闲</option>
|
|
|
- <option value="OFFLINE">离线</option>
|
|
|
- </select>
|
|
|
+ <UiSelect v-model="form.status" :options="stockEditableStatusOptions" />
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="form-col">
|
|
|
@@ -159,24 +149,24 @@
|
|
|
>{{ probe.loading ? '获取中...' : '自动获取以下配置' }}</button>
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
- <label>CPU 型号</label>
|
|
|
- <input v-model.trim="form.cpuModel" type="text" placeholder="例如 Intel" :disabled="isWorkingEdit" />
|
|
|
+ <label>CPU 型号 <span class="required">*</span></label>
|
|
|
+ <input v-model.trim="form.cpuModel" type="text" placeholder="例如 Intel" :disabled="isManagedLifecycleEdit" />
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
<label>CPU 核心数 <span class="required">*</span></label>
|
|
|
- <input v-model.number="form.cpuCores" type="number" min="4" max="127" step="4" placeholder="例如 8" :disabled="isWorkingEdit" />
|
|
|
+ <input v-model.number="form.cpuCores" type="number" min="4" max="127" step="4" placeholder="例如 8" :disabled="isManagedLifecycleEdit" />
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
<label>内存 (GB) <span class="required">*</span></label>
|
|
|
- <input v-model.number="form.ramGb" type="number" min="4" step="4" placeholder="例如 320" :disabled="isWorkingEdit" />
|
|
|
+ <input v-model.number="form.ramGb" type="number" min="4" step="4" placeholder="例如 320" :disabled="isManagedLifecycleEdit" />
|
|
|
</div>
|
|
|
<div class="form-field">
|
|
|
<label>SSD (GB) <span class="required">*</span></label>
|
|
|
- <input v-model.number="form.ssdGb" type="number" min="1024" step="1024" placeholder="例如 2048" :disabled="isWorkingEdit" />
|
|
|
+ <input v-model.number="form.ssdGb" type="number" min="1024" step="1024" placeholder="例如 2048" :disabled="isManagedLifecycleEdit" />
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div v-if="isWorkingEdit" class="tips-banner">当前机器为工作中状态,仅可修改备注。</div>
|
|
|
+ <div v-if="isManagedLifecycleEdit" class="tips-banner">当前机器为工作中或加入中状态,仅可修改备注。</div>
|
|
|
<div v-if="dialog.error" class="error-banner"><span>{{ dialog.error }}</span><button class="error-banner-close" @click="dialog.error = ''">×</button></div>
|
|
|
<div class="modal-actions">
|
|
|
<button class="btn btn-primary" :disabled="dialog.submitting" @click="submitForm">
|
|
|
@@ -193,12 +183,29 @@
|
|
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
|
|
import { useRouter } from 'vue-router';
|
|
|
import Sidebar from '../../components/Sidebar.vue';
|
|
|
+import UiSelect from '../../components/UiSelect.vue';
|
|
|
import { strongConfirm } from '../../utils/strongConfirm';
|
|
|
+import { formatDisplayId } from '../../utils/displayId';
|
|
|
import { createStock, deleteStock, probeStock, queryStocks, updateStock } from '../../api/stock';
|
|
|
import { listClusters, listClusterNodes } from '../../api/cluster';
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
+const stockFilterStatusOptions = [
|
|
|
+ { label: '全部', value: '' },
|
|
|
+ { label: '空闲', value: 'IDLE' },
|
|
|
+ { label: '加入中', value: 'JOINING' },
|
|
|
+ { label: '工作中', value: 'WORKING' },
|
|
|
+ { label: '离线', value: 'OFFLINE' }
|
|
|
+];
|
|
|
+
|
|
|
+const stockEditableStatusOptions = [
|
|
|
+ { label: '空闲', value: 'IDLE' },
|
|
|
+ { label: '离线', value: 'OFFLINE' }
|
|
|
+];
|
|
|
+
|
|
|
+const pageSizeOptions = [10, 20, 50].map(item => ({ label: String(item), value: item }));
|
|
|
+
|
|
|
const filters = reactive({ status: '', ipAddress: '' });
|
|
|
|
|
|
const table = reactive({
|
|
|
@@ -286,7 +293,7 @@ function nextPage() {
|
|
|
}
|
|
|
|
|
|
async function handleDelete(item) {
|
|
|
- if (!(await strongConfirm(`确认删除机器 ${item.ipAddress}(ID: ${item.machineId})?`))) return;
|
|
|
+ if (!(await strongConfirm(`确认删除机器 ${item.ipAddress}(${formatDisplayId(item.machineId)})?`))) return;
|
|
|
table.error = '';
|
|
|
try {
|
|
|
await deleteStock(item.machineId);
|
|
|
@@ -325,7 +332,7 @@ function emptyForm() {
|
|
|
const form = reactive(emptyForm());
|
|
|
const dialog = reactive({ visible: false, isEdit: false, editId: null, editStatus: null, submitting: false, error: '' });
|
|
|
const probe = reactive({ loading: false });
|
|
|
-const isWorkingEdit = computed(() => dialog.isEdit && dialog.editStatus === 'WORKING');
|
|
|
+const isManagedLifecycleEdit = computed(() => dialog.isEdit && ['WORKING', 'JOINING'].includes(dialog.editStatus));
|
|
|
|
|
|
function openCreateDialog() {
|
|
|
Object.assign(form, emptyForm());
|
|
|
@@ -374,7 +381,8 @@ async function autoFetchConfig() {
|
|
|
}
|
|
|
|
|
|
function validateForm() {
|
|
|
- if (isWorkingEdit.value) return null;
|
|
|
+ if (form.description && form.description.length > 50) return '备注不能超过 50 个字符';
|
|
|
+ if (isManagedLifecycleEdit.value) return null;
|
|
|
if (!form.ipAddress) return '请输入 IP 地址';
|
|
|
if (!isValidIpAddress(form.ipAddress)) return 'IP 地址格式不合法';
|
|
|
if (!form.sshUsername) return '请输入 SSH 用户名';
|
|
|
@@ -383,7 +391,6 @@ function validateForm() {
|
|
|
if (!form.ramGb) return '请输入内存大小';
|
|
|
if (!form.ssdGb) return '请输入 SSD 大小';
|
|
|
if (!dialog.isEdit && !form.sshPassword) return '请输入 SSH 密码';
|
|
|
- if (form.description && form.description.length > 50) return '备注不能超过 50 个字符';
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
@@ -405,7 +412,7 @@ async function submitForm() {
|
|
|
try {
|
|
|
if (dialog.isEdit) {
|
|
|
let payload = { description: form.description };
|
|
|
- if (!isWorkingEdit.value) {
|
|
|
+ if (!isManagedLifecycleEdit.value) {
|
|
|
payload = {
|
|
|
ipAddress: form.ipAddress,
|
|
|
cpuCores: form.cpuCores,
|
|
|
@@ -437,20 +444,34 @@ async function submitForm() {
|
|
|
const { data } = await createStock(payload);
|
|
|
table.list.unshift(data);
|
|
|
table.total += 1;
|
|
|
- showToast('纳管成功');
|
|
|
+ showToast('纳管请求已提交');
|
|
|
}
|
|
|
dialog.visible = false;
|
|
|
} catch (err) {
|
|
|
- dialog.error = err?.response?.data?.message || '提交失败';
|
|
|
+ dialog.error = resolveSubmitError(err);
|
|
|
} finally {
|
|
|
dialog.submitting = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function resolveSubmitError(err) {
|
|
|
+ const message = err?.response?.data?.message;
|
|
|
+ if (message) {
|
|
|
+ return message;
|
|
|
+ }
|
|
|
+ const errorCode = err?.code || '';
|
|
|
+ const errorText = String(err?.message || '').toLowerCase();
|
|
|
+ if (errorCode === 'ECONNABORTED' || errorText.includes('timeout')) {
|
|
|
+ return 'SSH连接失败:请求超时,请检查目标机器22端口、SSH用户名和密码后重试。';
|
|
|
+ }
|
|
|
+ return '提交失败';
|
|
|
+}
|
|
|
+
|
|
|
function statusBadgeClass(status) {
|
|
|
return {
|
|
|
'status-badge': true,
|
|
|
'status-idle': status === 'IDLE',
|
|
|
+ 'status-joining': status === 'JOINING',
|
|
|
'status-working': status === 'WORKING',
|
|
|
'status-offline': status === 'OFFLINE'
|
|
|
};
|
|
|
@@ -459,6 +480,7 @@ function statusBadgeClass(status) {
|
|
|
function stockStatusLabel(status) {
|
|
|
const mapping = {
|
|
|
IDLE: '空闲',
|
|
|
+ JOINING: '加入中',
|
|
|
WORKING: '工作中',
|
|
|
OFFLINE: '离线'
|
|
|
};
|
|
|
@@ -624,10 +646,8 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.filter-item input,
|
|
|
-.filter-item select,
|
|
|
.form-field input,
|
|
|
-.form-field select,
|
|
|
-.pagination select {
|
|
|
+.pagination input {
|
|
|
padding: 8px 12px;
|
|
|
border: 1px solid var(--border-color);
|
|
|
border-radius: 4px;
|
|
|
@@ -639,10 +659,8 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.filter-item input:focus,
|
|
|
-.filter-item select:focus,
|
|
|
.form-field input:focus,
|
|
|
-.form-field select:focus,
|
|
|
-.pagination select:focus {
|
|
|
+.pagination input:focus {
|
|
|
outline: none;
|
|
|
border-color: var(--accent);
|
|
|
}
|
|
|
@@ -668,6 +686,7 @@ onUnmounted(() => {
|
|
|
}
|
|
|
|
|
|
.table-wrapper { overflow-x: auto; }
|
|
|
+.table-wrapper > table { min-width: 1180px; }
|
|
|
|
|
|
table {
|
|
|
width: 100%;
|
|
|
@@ -732,8 +751,34 @@ tbody tr:hover { background: var(--accent-hover); }
|
|
|
|
|
|
.action-cell {
|
|
|
display: flex;
|
|
|
+ align-items: center;
|
|
|
gap: 6px;
|
|
|
white-space: nowrap;
|
|
|
+ width: 166px;
|
|
|
+ min-width: 166px;
|
|
|
+ padding-left: 8px;
|
|
|
+ padding-right: 4px;
|
|
|
+ position: sticky;
|
|
|
+ right: 0;
|
|
|
+ z-index: 11;
|
|
|
+ background: var(--panel-bg);
|
|
|
+ box-shadow: -8px 0 10px -10px rgba(31, 35, 41, 0.32);
|
|
|
+}
|
|
|
+
|
|
|
+.action-column {
|
|
|
+ width: 166px;
|
|
|
+ min-width: 166px;
|
|
|
+ padding-left: 8px;
|
|
|
+ padding-right: 4px;
|
|
|
+ position: sticky;
|
|
|
+ right: 0;
|
|
|
+ z-index: 12;
|
|
|
+ background: var(--page-bg);
|
|
|
+ box-shadow: -8px 0 10px -10px rgba(31, 35, 41, 0.32);
|
|
|
+}
|
|
|
+
|
|
|
+tbody tr:hover .action-cell {
|
|
|
+ background: var(--accent-hover);
|
|
|
}
|
|
|
|
|
|
.status-badge {
|
|
|
@@ -746,6 +791,7 @@ tbody tr:hover { background: var(--accent-hover); }
|
|
|
}
|
|
|
|
|
|
.status-idle { background: #f0f9eb; color: #67c23a; border: 1px solid #b3e19d; }
|
|
|
+.status-joining { background: #fff7e6; color: #e6a23c; border: 1px solid #f5d08a; }
|
|
|
.status-working { background: #ecf5ff; color: #409eff; border: 1px solid #a0cfff; }
|
|
|
.status-offline { background: #f4f4f5; color: #909399; border: 1px solid #d3d4d6; }
|
|
|
|
|
|
@@ -766,6 +812,13 @@ tbody tr:hover { background: var(--accent-hover); }
|
|
|
color: var(--text-regular);
|
|
|
}
|
|
|
|
|
|
+.page-size-select {
|
|
|
+ display: inline-block;
|
|
|
+ width: 76px;
|
|
|
+ margin: 0 6px;
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+
|
|
|
.pagination-right {
|
|
|
display: flex;
|
|
|
align-items: center;
|