Files
frontend/src/views/InstanceDetail.vue
NanamiAdmin ba78f77243 style(ui): add custom scrollbar styling to multiple components
Implement consistent scrollbar styling across Home, SideBar, and InstanceDetail components using CSS variables for theming. The styling includes track and thumb colors with hover effects for better visual feedback.
2026-03-26 17:32:57 +08:00

923 lines
29 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, 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 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 = () => {
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
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,
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;
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>