Files
frontend/src/views/InstanceDetail.vue
NanamiAdmin c53854722d refactor: reorganize logger module and update proxy display
Move logger.js from root to utils directory for better project structure
Update InstanceDetail view to show full remote address instead of just port
2026-04-01 22:01:35 +08:00

1055 lines
35 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>
<div class="proxy-actions-right">
<span class="proxy-type">{{ proxy.type }}</span>
<div class="proxy-actions">
<button
class="common-btn edit-btn"
@click="editProxy(proxy)"
>
<i class="fas fa-edit"></i> Edit
</button>
<button
class="common-btn delete-btn"
@click="deleteProxy(proxy)"
>
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</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 Address</span>
<span class="config-value">{{ instanceConfig['Server Address'] || '' }}:{{ 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>{{ isEditProxy ? 'Edit Proxy' : 'Add Proxy' }}</h3>
<form @submit.prevent="handleProxySubmit" 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...' : (isEditProxy ? 'Save' : 'Submit') }}</button>
</div>
</form>
</div>
</div>
<div v-if="showDeleteProxyModal" class="modal-overlay" @click="closeDeleteProxyModal">
<div class="modal" @click.stop>
<h3>Delete Proxy</h3>
<p>Are you sure you want to delete proxy <strong>{{ selectedProxyName }}</strong>?</p>
<div class="form-actions">
<button type="button" class="cancel-btn" @click="closeDeleteProxyModal">Cancel</button>
<button type="button" class="submit-btn delete-btn" @click="confirmDeleteProxy" :disabled="loading">
{{ loading ? 'Processing...' : 'Delete' }}
</button>
</div>
</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, onUnmounted } 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 showDeleteProxyModal = ref(false);
const isEditProxy = ref(false);
const selectedProxyName = ref('');
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 loadInstanceInfo = async () => {
try {
const result = await instanceApi.getInstanceInfo(instanceID.value);
instanceName.value = result.data.name;
instanceStatus.value = result.data.isRunning ? 'running' : 'stopped';
statusInfo.value = {
pid: result.data.pid,
serviceName: result.data.serviceName,
isRunning: result.data.isRunning
};
instanceConfig.value = {
'Server Address': result.data.serverAddr || '',
'Server Port': result.data.serverPort || '',
'Auth Method': result.data.auth_method,
'Boot At Start': result.data.bootAtStart ? 'Yes' : 'No',
'Run User': result.data.runUser,
'Config Path': result.data.configPath,
'Created At': formatDate(result.data.createdAt)
};
} catch (error) {
showNotification('Load instance information failed', 'error');
}
};
const startInstance = async () => {
try {
await instanceApi.startInstance(instanceID.value);
showNotification('Instance started successfully', 'success');
await loadInstanceInfo();
} catch (error) {
showNotification(error.message || 'Start instance failed', 'error');
}
};
const stopInstance = async () => {
try {
await instanceApi.stopInstance(instanceID.value);
showNotification('Instance stopped successfully', 'success');
await loadInstanceInfo();
} 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 loadInstanceInfo();
} 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');
}
};
let logSocket = null;
const connectLogWebSocket = () => {
const token = getCookie('token');
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const target = __APP_TARGET__;
const wsUrl = `${wsProtocol}//${target}/frpcAct/instanceMgr/logs?instanceID=${instanceID.value}&token=${token}`;
logSocket = new WebSocket(wsUrl);
logSocket.onopen = () => {
console.log(`Connected to instance ${instanceID.value} log server`);
};
logSocket.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
logs.value.unshift({
time: log.timestamp,
level: log.level,
message: log.content
});
if (logs.value.length > 100) {
logs.value.pop();
}
} catch (error) {
console.error('Error parsing log message:', error);
}
};
logSocket.onclose = () => {
console.log(`Disconnected from instance ${instanceID.value} log server`);
};
logSocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
};
const disconnectLogWebSocket = () => {
if (logSocket) {
logSocket.close();
logSocket = null;
}
};
const loadLogs = async () => {
try {
const result = await instanceApi.getInstanceInfo(instanceID.value);
logs.value = result.data.logs ? result.data.logs.map(log => ({
time: formatDate(log.timestamp),
level: log.level,
message: log.content
})) : [];
} catch (error) {
showNotification('Load logs failed', 'error');
}
connectLogWebSocket();
};
const goBack = () => {
router.push('/instances');
};
const editConfig = async () => {
try {
const result = await instanceApi.getInstanceInfo(instanceID.value);
instanceName.value = result.data.name;
formData.value = {
name: result.data.name,
auth_method: result.data.auth_method || 'token',
serverAddr: result.data.serverAddr || '',
serverPort: result.data.serverPort || '',
token: '',
clientId: '',
clientSecret: '',
audience: '',
tokenEndpoint: '',
bootAtStart: result.data.bootAtStart || false,
runUser: result.data.runUser || 'root'
};
} catch (error) {
showNotification('Load instance data failed', 'error');
return;
}
showEditConfigModal.value = true;
};
const addProxy = () => {
isEditProxy.value = false;
formData.value = {
name: '',
type: '',
localIP: '',
localPort: '',
remotePort: '',
auth_method: formData.value.auth_method,
serverAddr: formData.value.serverAddr,
serverPort: formData.value.serverPort,
token: formData.value.token,
clientId: formData.value.clientId,
clientSecret: formData.value.clientSecret,
audience: formData.value.audience,
tokenEndpoint: formData.value.tokenEndpoint,
bootAtStart: formData.value.bootAtStart,
runUser: formData.value.runUser
};
showAddProxyModal.value = true;
}
const editProxy = (proxy) => {
isEditProxy.value = true;
formData.value = {
name: proxy.name,
type: proxy.type,
localIP: proxy.localIP,
localPort: parseInt(proxy.localPort),
remotePort: parseInt(proxy.remotePort),
auth_method: formData.value.auth_method,
serverAddr: formData.value.serverAddr,
serverPort: formData.value.serverPort,
token: formData.value.token,
clientId: formData.value.clientId,
clientSecret: formData.value.clientSecret,
audience: formData.value.audience,
tokenEndpoint: formData.value.tokenEndpoint,
bootAtStart: formData.value.bootAtStart,
runUser: formData.value.runUser
};
showAddProxyModal.value = true;
}
const deleteProxy = (proxy) => {
selectedProxyName.value = proxy.name;
showDeleteProxyModal.value = true;
}
const closeDeleteProxyModal = () => {
showDeleteProxyModal.value = false;
selectedProxyName.value = '';
}
const closeAddProxyModal = () => {
showAddProxyModal.value = false;
selectedProxyName.value = '';
};
const closeEditConfigModal = () => {
showEditConfigModal.value = false;
};
const confirmDeleteProxy = async () => {
loading.value = true;
try {
await instanceApi.deleteProxy(instanceID.value, selectedProxyName.value);
showNotification('Proxy deleted successfully', 'success');
closeDeleteProxyModal();
await loadProxies();
} catch (error) {
showNotification(error.message || 'Delete proxy failed', 'error');
} finally {
loading.value = false;
}
};
const handleProxySubmit = 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()
};
if (isEditProxy.value) {
await instanceApi.modifyProxy(instanceID.value, proxyInfo);
showNotification('Proxy updated successfully', 'success');
} else {
await instanceApi.createProxy(instanceID.value, proxyInfo);
showNotification('Proxy created successfully', 'success');
}
closeAddProxyModal();
await loadProxies();
} catch (error) {
showNotification(error.message || (isEditProxy.value ? 'Update proxy failed' : 'Create proxy failed'), 'error');
} finally {
loading.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
serverAddr: formData.value.serverAddr,
serverPort: 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 loadInstanceInfo();
} 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 loadInstanceInfo();
await loadProxies();
await loadLogs();
});
onUnmounted(() => {
disconnectLogWebSocket();
});
return {
instanceID,
instanceName,
instanceConfig,
proxies,
logs,
instanceStatus,
statusInfo,
userType,
showEditConfigModal,
showAddProxyModal,
showDeleteProxyModal,
isEditProxy,
selectedProxyName,
loading,
formData,
goBack,
editConfig,
closeEditConfigModal,
addProxy,
editProxy,
deleteProxy,
closeAddProxyModal,
closeDeleteProxyModal,
confirmDeleteProxy,
handleEditConfiguration,
handleProxySubmit,
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;
padding: 2px 8px;
border-radius: 4px;
}
.log-level.DEBUG {
color: #52c41a;
background: #f6ffed;
}
.log-level.INFO {
color: #1890ff;
background: #e6f7ff;
}
.log-level.WARN {
color: #faad14;
background: #fffbe6;
}
.log-level.ERROR {
color: #ff4d4f;
background: #fff1f0;
}
.log-level.FATAL {
color: #cf1322;
background: #fff1f0;
}
.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;
}
.section:last-child .logs-container {
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.section:last-child .logs-container::-webkit-scrollbar {
width: 8px;
}
.section:last-child .logs-container::-webkit-scrollbar-track {
background: var(--bg-color);
border-radius: 0 8px 8px 0;
}
.section:last-child .logs-container::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 4px;
}
.section:last-child .logs-container::-webkit-scrollbar-thumb:hover {
background: var(--sidebar-active-bg);
}
</style>