Files
frontend/src/views/InstanceDetail.vue
NanamiAdmin d70193c15d feat(InstanceDetail): add delete instance button and functionality
Implement instance deletion feature with confirmation notification and navigation back to instances list. Also adjust header layout to accommodate the new button.
2026-03-25 10:49:08 +08:00

846 lines
27 KiB
Vue

<template>
<div class="instance-detail-page">
<div class="page-header">
<button class="common-btn" @click="goBack">
<i class="fas fa-arrow-left"></i> Back
</button>
<h2>{{ instanceName }} - Details</h2>
<button class="common-btn" @click="deleteInstance">
<i class="fas fa-trash"></i> Delete
</button>
</div>
<div class="detail-content">
<div class="section">
<h3>Instance Status
<div class="action-buttons">
<button
v-if="userType !== 'visitor' && instanceStatus === 'running'"
class="common-btn restart-btn"
@click="restartInstance"
>
<i class="fas fa-redo"></i> Restart
</button>
<button
v-if="userType !== 'visitor'"
class="common-btn status-btn"
:class="instanceStatus === 'running' ? 'stop-btn' : 'start-btn'"
@click="handleStatusClick"
>
<i :class="['fas', instanceStatus === 'running' ? 'fa-stop' : 'fa-play']"></i>
{{ instanceStatus === 'running' ? 'Stop' : 'Start' }}
</button>
</div>
</h3>
<div class="status-list">
<div class="status-item">
<span class="status-key">Status:</span>
<span :class="['status-value', instanceStatus]">
{{ instanceStatus === 'running' ? 'Running' : 'Stopped' }}
</span>
</div>
<div class="status-item" v-if="instanceStatus === 'running' && statusInfo.pid">
<span class="status-key">PID:</span>
<span class="status-value">{{ statusInfo.pid }}</span>
</div>
<div class="status-item" v-if="statusInfo.serviceName">
<span class="status-key">Service Name:</span>
<span class="status-value">{{ statusInfo.serviceName }}</span>
</div>
</div>
</div>
<div class="section">
<h3>Configuration
<button class="common-btn" @click="editConfig">
<i class="fas fa-edit"></i> Edit
</button>
</h3>
<div class="config-list">
<div class="config-item" v-for="(value, key) in instanceConfig" :key="key">
<span class="config-key">{{ key }}:</span>
<span class="config-value">{{ value }}</span>
</div>
</div>
</div>
<div v-if="showEditConfigModal" class="modal-overlay" @click="closeEditConfigModal">
<div class="modal" @click.stop>
<h3>Edit Configuration</h3>
<form @submit.prevent="handleEditConfiguration" class="form">
<div class="form-group">
<label>Instance Name</label>
<input
type="text"
v-model="formData.name"
required
>
</div>
<div class="form-group">
<label>Authentication Method</label>
<select v-model="formData.auth_method" required>
<option value="token">Token</option>
<option value="oidc">OIDC</option>
</select>
</div>
<div class="form-group">
<label>Server Address</label>
<input
type="text"
v-model="formData.serverAddr"
required
>
</div>
<div class="form-group">
<label>Server Port</label>
<input
type="text"
v-model="formData.serverPort"
required
>
</div>
<div class="form-group" v-if="formData.auth_method === 'token'">
<label>Token</label>
<input
type="text"
v-model="formData.token"
required
>
</div>
<div v-if="formData.auth_method === 'oidc'">
<div class="form-group">
<label>Client ID</label>
<input
type="text"
v-model="formData.clientId"
required
>
</div>
<div class="form-group">
<label>Client Secret</label>
<input
type="text"
v-model="formData.clientSecret"
required
>
</div>
<div class="form-group">
<label>Audience</label>
<input
type="text"
v-model="formData.audience"
required
>
</div>
<div class="form-group">
<label>Token Endpoint URL</label>
<input
type="url"
v-model="formData.tokenEndpoint"
required
>
</div>
</div>
<div class="form-group checkbox-group">
<input
type="checkbox"
id="bootAtStart"
v-model="formData.bootAtStart"
>
<label for="bootAtStart">Boot at Start</label>
</div>
<div class="form-group">
<label>Run User</label>
<input
type="text"
v-model="formData.runUser"
placeholder="Default: root"
>
</div>
<div class="form-actions">
<button type="button" class="cancel-btn" @click="closeEditConfigModal">Cancel</button>
<button type="submit" class="submit-btn" :disabled="loading">{{ loading ? 'Processing...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
<div class="section">
<h3>Proxy List
<button class="common-btn" @click="addProxy">
<i class="fas fa-plus"></i> Add Proxy
</button>
</h3>
<div v-if="proxies.length > 0" class="proxy-list">
<div v-for="proxy in proxies" :key="proxy.name" class="proxy-item">
<div class="proxy-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="proxy-type">{{ proxy.type }}</span>
</div>
<div class="proxy-details">
<div class="config-item proxy-detail">
<span class="config-key">Local Address</span>
<span class="config-value">{{ proxy.localIP }}:{{ proxy.localPort }}</span>
</div>
<div class="config-item proxy-detail">
<span class="config-key">Remote Port</span>
<span class="config-value">{{ proxy.remotePort }}</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>No proxies available</p>
</div>
</div>
<div v-if="showAddProxyModal" class="modal-overlay" @click="closeAddProxyModal">
<div class="modal" @click.stop>
<h3>Add Proxy</h3>
<form @submit.prevent="handleAddProxySubmit" class="form">
<div class="form-group">
<label>Proxy Name</label>
<input
type="text"
v-model="formData.name"
required
>
</div>
<div class="form-group">
<label>Type</label>
<input
type="text"
v-model="formData.type"
required
>
</div>
<div class="form-group">
<label>Local IP</label>
<input
type="text"
v-model="formData.localIP"
required
>
</div>
<div class="form-group">
<label>Local Port</label>
<input
type="number"
v-model="formData.localPort"
required
min="0"
max="65535"
step="1"
>
</div>
<div class="form-group">
<label>Remote Port</label>
<input
type="number"
v-model="formData.remotePort"
required
min="0"
max="65535"
step="1"
>
</div>
<div class="form-actions">
<button type="button" class="cancel-btn" @click="closeAddProxyModal">Cancel</button>
<button type="submit" class="submit-btn" :disabled="loading">{{ loading ? 'Processing...' : 'Submit' }}</button>
</div>
</form>
</div>
</div>
<div class="section">
<h3>Logs</h3>
<div class="logs-container">
<div v-if="logs.length > 0" class="log-list">
<div v-for="(log, index) in logs" :key="index" class="log-item">
<span class="log-time">{{ log.time }}</span>
<span :class="['log-level', log.level]">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No logs available</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { instanceApi } from '../api/index.js';
import { showNotification, formatDate, getCookie } from '../utils/functions.js';
export default {
name: 'InstanceDetail',
setup() {
const route = useRoute();
const router = useRouter();
const instanceID = ref(route.params.id);
const instanceName = ref('');
const instanceConfig = ref({});
const proxies = ref([]);
const logs = ref([]);
const instanceStatus = ref('stopped');
const statusInfo = ref({});
const showEditConfigModal = ref(false);
const showAddProxyModal = ref(false);
const loading = ref(false);
const userType = ref(getCookie('user-type') || 'visitor');
const formData = ref({
name: '',
auth_method: 'token',
serverAddr: '',
serverPort: '',
token: '',
clientId: '',
clientSecret: '',
audience: '',
tokenEndpoint: '',
bootAtStart: false,
runUser: ''
});
const loadInstanceConfig = async () => {
try {
const result = await instanceApi.listInstances();
const instance = result.data.find(i => String(i.instanceID) === instanceID.value);
if (instance) {
instanceName.value = instance.name;
instanceConfig.value = {
'Server Address': instance.serverAddr,
'Server Port': instance.serverPort,
'Auth Method': instance.auth_method,
'Boot At Start': instance.bootAtStart ? 'Yes' : 'No',
'Run User': instance.runUser,
'Config Path': instance.configPath,
'Created At': formatDate(instance.createdAt)
};
}
} catch (error) {
showNotification('Load instance configuration failed', 'error');
}
};
const loadInstanceStatus = async () => {
try {
const result = await instanceApi.getInstanceStatus(instanceID.value);
instanceStatus.value = result.data.status;
statusInfo.value = result.data;
} catch (error) {
showNotification('Load instance status failed', 'error');
}
};
const startInstance = async () => {
try {
await instanceApi.startInstance(instanceID.value);
showNotification('Instance started successfully', 'success');
await loadInstanceStatus();
} catch (error) {
showNotification(error.message || 'Start instance failed', 'error');
}
};
const stopInstance = async () => {
try {
await instanceApi.stopInstance(instanceID.value);
showNotification('Instance stopped successfully', 'success');
await loadInstanceStatus();
} catch (error) {
showNotification(error.message || 'Stop instance failed', 'error');
}
};
const deleteInstance = async () => {
try {
await instanceApi.deleteInstance(instanceID.value);
showNotification('Instance deleted successfully', 'success');
router.push('/instances');
} catch (error) {
showNotification(error.message || 'Delete instance failed', 'error');
}
};
const restartInstance = async () => {
try {
await instanceApi.restartInstance(instanceID.value);
showNotification('Instance restarted successfully', 'success');
await loadInstanceStatus();
} catch (error) {
showNotification(error.message || 'Restart instance failed', 'error');
}
};
const handleStatusClick = async () => {
if (instanceStatus.value === 'running') {
await stopInstance();
} else {
await startInstance();
}
};
const loadProxies = async () => {
try {
const result = await instanceApi.getInstanceProxies(instanceID.value);
const proxyList = result.data.proxies || [];
proxies.value = proxyList.map(proxy => ({
name: proxy.name,
type: proxy.type,
localIP: proxy.localIP,
localPort: proxy.localPort,
remotePort: proxy.remotePort
}));
} catch (error) {
showNotification('Load proxy list failed', 'error');
}
};
const loadLogs = async () => {
try {
const result = await instanceApi.getInstanceLogs(instanceID.value);
logs.value = result.data.map(log => ({
time: formatDate(log.timestamp),
level: log.level,
message: log.message
}));
} catch (error) {
showNotification('Load logs failed', 'error');
}
};
const goBack = () => {
router.push('/instances');
};
const editConfig = async () => {
try {
const result = await instanceApi.listInstances();
const instance = result.data.find(i => String(i.instanceID) === instanceID.value);
if (instance) {
instanceName.value = instance.name;
formData.value = {
name: instance.name,
auth_method: instance.auth_method || 'token',
serverAddr: instance.serverAddr || '',
serverPort: instance.serverPort || '',
token: '',
clientId: '',
clientSecret: '',
audience: '',
tokenEndpoint: '',
bootAtStart: instance.bootAtStart || false,
runUser: instance.runUser || 'root'
};
}
} catch (error) {
showNotification('Load instance data failed', 'error');
return;
}
showEditConfigModal.value = true;
};
const addProxy = () => {
showAddProxyModal.value = true;
}
const closeEditConfigModal = () => {
showEditConfigModal.value = false;
};
const closeAddProxyModal = () => {
showAddProxyModal.value = false;
};
const handleEditConfiguration = async () => {
loading.value = true;
try {
const systemConfigData = { // Update system config
name: formData.value.name,
bootAtStart: formData.value.bootAtStart,
runUser: formData.value.runUser || 'root'
};
await instanceApi.modifyInstance(
instanceID.value,
'systemConfig',
systemConfigData
);
const configFileData = { // Update config file
server_addr: formData.value.serverAddr,
server_port: formData.value.serverPort,
auth_method: formData.value.auth_method
};
if (formData.value.auth_method === 'token') {
configFileData.auth_token = formData.value.token;
} else if (formData.value.auth_method === 'oidc') {
configFileData.oidc_client_id = formData.value.clientId;
configFileData.oidc_client_secret = formData.value.clientSecret;
configFileData.oidc_audience = formData.value.audience;
configFileData.oidc_token_endpoint = formData.value.tokenEndpoint;
}
await instanceApi.modifyInstance(
instanceID.value,
'configFile',
configFileData
);
showNotification('Configuration saved successfully', 'success');
closeEditConfigModal();
closeAddProxyModal();
// 重新加载配置
await loadInstanceConfig();
} catch (error) {
showNotification(error.message || 'Save configuration failed', 'error');
} finally {
loading.value = false;
}
};
const handleAddProxySubmit = async () => {
loading.value = true;
try {
const proxyInfo = {
name: formData.value.name,
type: formData.value.type,
localIP: formData.value.localIP,
localPort: formData.value.localPort.toString(),
remotePort: formData.value.remotePort.toString()
};
await instanceApi.createProxy(instanceID.value, proxyInfo);
showNotification('Proxy created successfully', 'success');
closeAddProxyModal();
await loadProxies();
} catch (error) {
showNotification(error.message || 'Create proxy failed', 'error');
} finally {
loading.value = false;
}
};
onMounted(async () => {
await loadInstanceConfig();
await loadInstanceStatus();
await loadProxies();
await loadLogs();
});
return {
instanceID,
instanceName,
instanceConfig,
proxies,
logs,
instanceStatus,
statusInfo,
userType,
showEditConfigModal,
showAddProxyModal,
loading,
formData,
goBack,
editConfig,
closeEditConfigModal,
addProxy,
closeAddProxyModal,
handleEditConfiguration,
handleAddProxySubmit,
handleStatusClick,
restartInstance,
deleteInstance
};
}
};
</script>
<style scoped>
@import '../styles/common.css';
.instance-detail-page {
padding: 24px;
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
justify-content: space-between;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.proxy-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.proxy-item {
padding: 16px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
}
.proxy-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.proxy-name {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.proxy-type {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
background: var(--primary-bg);
color: var(--primary-color);
text-transform: uppercase;
}
.proxy-detail {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
border: 5px solid var(--border-color);
}
.proxy-key {
color: var(--text-secondary);
font-weight: 500;
min-width: 90px;
}
.proxy-value {
color: var(--text-color);
font-family: monospace;
}
.log-item {
padding: 8px;
border-radius: 4px;
}
.log-time {
min-width: 140px;
}
.log-level {
min-width: 60px;
font-weight: 500;
}
.log-level.INFO {
color: #1890ff;
}
.log-level.WARN {
color: #faad14;
}
.log-level.ERROR {
color: #ff4d4f;
}
.empty-state {
padding: 40px 20px;
font-size: 14px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--card-bg);
border-radius: 8px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal h3 {
margin-top: 0;
margin-bottom: 20px;
color: var(--text-color);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.cancel-btn {
padding: 10px 20px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: transparent;
color: var(--text-color);
cursor: pointer;
transition: all 0.3s;
}
.cancel-btn:hover {
background: var(--hover-bg);
}
.submit-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
background: var(--primary-color);
color: white;
cursor: pointer;
transition: all 0.3s;
}
.submit-btn:hover {
background: var(--primary-hover);
}
.submit-btn:disabled {
background: var(--disabled-bg);
cursor: not-allowed;
}
.section h3 {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section h3 .action-buttons {
display: flex;
align-items: center;
gap: 8px;
}
.status-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-item {
padding: 16px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 12px;
}
.status-key {
color: var(--text-secondary);
font-weight: 500;
min-width: 90px;
}
.status-value {
color: var(--text-color);
font-family: monospace;
}
.status-value.running {
color: #52c41a;
font-weight: 600;
}
.status-value.stopped {
color: #ff4d4f;
font-weight: 600;
}
.common-btn.status-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #52c41a;
color: white;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: flex-end;
gap: 8px;
}
.common-btn.status-btn:hover {
background: #40a412;
}
.common-btn.stop-btn {
background: #ff4d4f;
}
.common-btn.stop-btn:hover {
background: #cf1616;
}
.common-btn.restart-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #1890ff;
color: white;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: flex-end;
gap: 8px;
}
.common-btn.restart-btn:hover {
background: #096dd9;
}
.status-btn i,
.restart-btn i {
font-size: 14px;
}
</style>