update
This commit is contained in:
@@ -42,6 +42,18 @@ interface PortInfo {
|
|||||||
}
|
}
|
||||||
// --- End FIX ---
|
// --- End FIX ---
|
||||||
|
|
||||||
|
// --- NEW: Stats Interface (Ensure this matches frontend) ---
|
||||||
|
interface DockerStats {
|
||||||
|
ID: string;
|
||||||
|
Name: string;
|
||||||
|
CPUPerc: string;
|
||||||
|
MemUsage: string;
|
||||||
|
MemPerc: string;
|
||||||
|
NetIO: string;
|
||||||
|
BlockIO: string;
|
||||||
|
PIDs: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- 新增:解析 Ports 字符串的辅助函数 ---
|
// --- 新增:解析 Ports 字符串的辅助函数 ---
|
||||||
function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined
|
function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined
|
||||||
if (!portsString) {
|
if (!portsString) {
|
||||||
@@ -680,6 +692,74 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NEW CASE: Handle docker:get_stats ---
|
||||||
|
case 'docker:get_stats': {
|
||||||
|
if (!state || !state.sshClient) { // Check state and sshClient
|
||||||
|
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`);
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId: payload?.containerId, message: 'SSH connection not active.' } }));
|
||||||
|
return; // Use return instead of break inside switch
|
||||||
|
}
|
||||||
|
if (!payload || !payload.containerId) {
|
||||||
|
console.warn(`WebSocket: Invalid payload for docker:get_stats in session ${sessionId}:`, payload);
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId: payload?.containerId, message: 'Missing containerId.' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerId = payload.containerId;
|
||||||
|
console.log(`WebSocket: Handling docker:get_stats for container ${containerId} in session ${sessionId}`);
|
||||||
|
const command = `docker stats ${containerId} --no-stream --format '{{json .}}'`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- FIX: Use sshClient.exec directly ---
|
||||||
|
const execResult = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
state.sshClient.exec(command, { pty: false }, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||||
|
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||||
|
stream.on('close', (code: number | null) => {
|
||||||
|
// Don't reject on non-zero exit code here, stderr check is more reliable for docker stats
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
});
|
||||||
|
stream.on('error', (execErr: Error) => reject(execErr));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// --- End FIX ---
|
||||||
|
|
||||||
|
if (execResult.stderr) {
|
||||||
|
// Handle cases like container not found or docker errors
|
||||||
|
console.error(`WebSocket: Docker stats stderr for ${containerId} in session ${sessionId}: ${execResult.stderr}`);
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: execResult.stderr.trim() || 'Error executing stats command.' } }));
|
||||||
|
return; // Use return after sending error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!execResult.stdout) {
|
||||||
|
console.warn(`WebSocket: No stats output for container ${containerId} in session ${sessionId}. Might be stopped or error occurred.`);
|
||||||
|
// Check stderr again just in case, although previous check should catch most errors
|
||||||
|
if (!execResult.stderr) {
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: 'No stats data received (container might be stopped).' } }));
|
||||||
|
}
|
||||||
|
return; // Use return after sending error or warning
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statsData = JSON.parse(execResult.stdout.trim());
|
||||||
|
// Optional: Clean up or format statsData if needed before sending
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:update', payload: { containerId, stats: statsData } }));
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(`WebSocket: Failed to parse docker stats JSON for ${containerId} in session ${sessionId}: ${execResult.stdout}`, parseError);
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: 'Failed to parse stats data.' } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`WebSocket: Failed to execute docker stats for ${containerId} in session ${sessionId}:`, error);
|
||||||
|
ws.send(JSON.stringify({ type: 'docker:stats:error', payload: { containerId, message: error.message || 'Failed to fetch Docker stats.' } }));
|
||||||
|
}
|
||||||
|
break; // Break after handling the case
|
||||||
|
} // --- END CASE: docker:get_stats ---
|
||||||
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`);
|
console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`);
|
||||||
ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${type}` }));
|
ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${type}` }));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { t } = useI18n();
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const { activeSession } = storeToRefs(sessionStore); // Get reactive active session
|
const { activeSession } = storeToRefs(sessionStore); // Get reactive active session
|
||||||
|
|
||||||
// --- Interfaces (Keep these) ---
|
// --- Interfaces ---
|
||||||
interface PortInfo {
|
interface PortInfo {
|
||||||
IP?: string;
|
IP?: string;
|
||||||
PrivatePort: number;
|
PrivatePort: number;
|
||||||
@@ -30,6 +30,20 @@ interface DockerContainer {
|
|||||||
Labels: Record<string, string>;
|
Labels: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NEW: Stats Interface (Example structure, adjust based on actual docker stats json output) ---
|
||||||
|
interface DockerStats {
|
||||||
|
ID: string;
|
||||||
|
Name: string;
|
||||||
|
CPUPerc: string; // e.g., "0.07%"
|
||||||
|
MemUsage: string; // e.g., "100MiB / 1.95GiB"
|
||||||
|
MemPerc: string; // e.g., "5.00%"
|
||||||
|
NetIO: string; // e.g., "1.5kB / 648B"
|
||||||
|
BlockIO: string; // e.g., "10MB / 0B"
|
||||||
|
PIDs: string; // e.g., "10"
|
||||||
|
// Add other fields if available from `docker stats --format json`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const containers = ref<DockerContainer[]>([]);
|
const containers = ref<DockerContainer[]>([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
@@ -37,6 +51,12 @@ const error = ref<string | null>(null);
|
|||||||
const isDockerAvailable = ref(true); // This will now reflect remote docker availability
|
const isDockerAvailable = ref(true); // This will now reflect remote docker availability
|
||||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions
|
let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions
|
||||||
|
// --- NEW: State for expansion ---
|
||||||
|
const expandedContainerId = ref<string | null>(null);
|
||||||
|
const containerStats = ref<DockerStats | null>(null);
|
||||||
|
const isStatsLoading = ref(false);
|
||||||
|
const statsError = ref<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
// --- Computed ---
|
// --- Computed ---
|
||||||
const currentSessionId = computed(() => activeSession.value?.sessionId);
|
const currentSessionId = computed(() => activeSession.value?.sessionId);
|
||||||
@@ -51,11 +71,10 @@ const clearWsListeners = () => {
|
|||||||
wsUnsubscribeHooks = [];
|
wsUnsubscribeHooks = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup WebSocket listeners for the current active session
|
// Setup WebSocket listeners
|
||||||
const setupWsListeners = () => {
|
const setupWsListeners = () => {
|
||||||
clearWsListeners(); // Clear previous listeners first
|
clearWsListeners(); // Clear previous listeners first
|
||||||
if (!activeSession.value) return;
|
if (!activeSession.value) return;
|
||||||
|
|
||||||
const wsManager = activeSession.value.wsManager;
|
const wsManager = activeSession.value.wsManager;
|
||||||
|
|
||||||
// Listener for Docker status updates
|
// Listener for Docker status updates
|
||||||
@@ -112,7 +131,31 @@ const setupWsListeners = () => {
|
|||||||
requestDockerStatus(); // Trigger a status refresh immediately
|
requestDockerStatus(); // Trigger a status refresh immediately
|
||||||
});
|
});
|
||||||
|
|
||||||
wsUnsubscribeHooks.push(unsubStatus, unsubStatusError, unsubCommandError, unsubRequestUpdate); // Add new unsubscribe hook
|
// --- NEW: Listen for stats updates ---
|
||||||
|
const unsubStatsUpdate = wsManager.onMessage('docker:stats:update', (payload) => {
|
||||||
|
// Ensure the update is for the currently expanded container
|
||||||
|
if (payload?.containerId === expandedContainerId.value) {
|
||||||
|
console.log(`[DockerManager] Received stats update for ${payload.containerId}:`, payload.stats);
|
||||||
|
containerStats.value = payload.stats as DockerStats; // Assuming payload.stats matches DockerStats
|
||||||
|
isStatsLoading.value = false;
|
||||||
|
statsError.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubStatsError = wsManager.onMessage('docker:stats:error', (payload) => {
|
||||||
|
if (payload?.containerId === expandedContainerId.value) {
|
||||||
|
console.error(`[DockerManager] Error fetching stats for ${payload.containerId}:`, payload.message);
|
||||||
|
containerStats.value = null;
|
||||||
|
isStatsLoading.value = false;
|
||||||
|
statsError.value = payload.message || t('dockerManager.stats.errorGeneric');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wsUnsubscribeHooks.push(
|
||||||
|
unsubStatus, unsubStatusError, unsubCommandError, unsubRequestUpdate, // existing unsub hooks
|
||||||
|
unsubStatsUpdate,
|
||||||
|
unsubStatsError
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -172,6 +215,37 @@ const sendDockerCommand = (containerId: string, command: 'start' | 'stop' | 'res
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- NEW: Method to toggle expansion and fetch stats ---
|
||||||
|
const toggleExpand = (containerId: string) => {
|
||||||
|
if (expandedContainerId.value === containerId) {
|
||||||
|
// Collapse
|
||||||
|
expandedContainerId.value = null;
|
||||||
|
containerStats.value = null;
|
||||||
|
statsError.value = null;
|
||||||
|
isStatsLoading.value = false;
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
expandedContainerId.value = containerId;
|
||||||
|
containerStats.value = null; // Clear previous stats
|
||||||
|
statsError.value = null;
|
||||||
|
isStatsLoading.value = true;
|
||||||
|
|
||||||
|
// Request stats from backend
|
||||||
|
if (activeSession.value && sshConnectionStatus.value === 'connected') {
|
||||||
|
console.log(`[DockerManager] Requesting stats for container ${containerId}`);
|
||||||
|
activeSession.value.wsManager.sendMessage({
|
||||||
|
type: 'docker:get_stats',
|
||||||
|
payload: { containerId }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[DockerManager] Cannot fetch stats, SSH not connected.');
|
||||||
|
statsError.value = t('dockerManager.error.sshNotConnected');
|
||||||
|
isStatsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Lifecycle and Watchers ---
|
// --- Lifecycle and Watchers ---
|
||||||
|
|
||||||
// Watch for changes in the active session OR SSH connection status
|
// Watch for changes in the active session OR SSH connection status
|
||||||
@@ -192,6 +266,15 @@ watch([currentSessionId, sshConnectionStatus], ([newSessionId, newSshStatus], [o
|
|||||||
console.log('[DockerManager] Cleared refresh interval.');
|
console.log('[DockerManager] Cleared refresh interval.');
|
||||||
}
|
}
|
||||||
clearWsListeners(); // Clear listeners on disconnect or session change
|
clearWsListeners(); // Clear listeners on disconnect or session change
|
||||||
|
// --- Add: Collapse container when session changes or disconnects ---
|
||||||
|
if (expandedContainerId.value && (newSessionId !== oldSessionId || (newSessionId && (newSshStatus === 'disconnected' || newSshStatus === 'error')))) {
|
||||||
|
console.log('[DockerManager] Session changed/disconnected, collapsing stats view.');
|
||||||
|
expandedContainerId.value = null;
|
||||||
|
containerStats.value = null;
|
||||||
|
statsError.value = null;
|
||||||
|
isStatsLoading.value = false;
|
||||||
|
}
|
||||||
|
// --- End Add ---
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Setup listeners and fetch data when session is active AND SSH is connected ---
|
// --- Setup listeners and fetch data when session is active AND SSH is connected ---
|
||||||
@@ -283,10 +366,10 @@ onUnmounted(() => {
|
|||||||
<div v-if="containers.length === 0 && !isLoading" class="empty-placeholder">
|
<div v-if="containers.length === 0 && !isLoading" class="empty-placeholder">
|
||||||
{{ t('dockerManager.noContainers') }}
|
{{ t('dockerManager.noContainers') }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Add class="responsive-table" -->
|
<table v-else class="responsive-table docker-table"> <!-- Add specific class -->
|
||||||
<table v-else class="responsive-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="col-expand"></th> <!-- Empty header for expand button -->
|
||||||
<th>{{ t('dockerManager.header.name') }}</th>
|
<th>{{ t('dockerManager.header.name') }}</th>
|
||||||
<th>{{ t('dockerManager.header.image') }}</th>
|
<th>{{ t('dockerManager.header.image') }}</th>
|
||||||
<th>{{ t('dockerManager.header.status') }}</th>
|
<th>{{ t('dockerManager.header.status') }}</th>
|
||||||
@@ -294,9 +377,17 @@ onUnmounted(() => {
|
|||||||
<th>{{ t('dockerManager.header.actions') }}</th>
|
<th>{{ t('dockerManager.header.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<!-- Use template v-for to render pairs of rows -->
|
||||||
<tr v-for="container in containers" :key="container.id">
|
<template v-for="container in containers" :key="container.id">
|
||||||
<!-- Add data-label attributes -->
|
<!-- Main Row -->
|
||||||
|
<tr>
|
||||||
|
<!-- FIX: Expand button TD inside the main TR -->
|
||||||
|
<td class="col-expand">
|
||||||
|
<button @click="toggleExpand(container.id)" class="expand-btn" :title="expandedContainerId === container.id ? t('common.collapse') : t('common.expand')">
|
||||||
|
<i :class="['fas', expandedContainerId === container.id ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<!-- End FIX -->
|
||||||
<td :data-label="t('dockerManager.header.name')">{{ container.Names?.join(', ') || 'N/A' }}</td>
|
<td :data-label="t('dockerManager.header.name')">{{ container.Names?.join(', ') || 'N/A' }}</td>
|
||||||
<td :data-label="t('dockerManager.header.image')">{{ container.Image }}</td>
|
<td :data-label="t('dockerManager.header.image')">{{ container.Image }}</td>
|
||||||
<td :data-label="t('dockerManager.header.status')">
|
<td :data-label="t('dockerManager.header.status')">
|
||||||
@@ -321,7 +412,67 @@ onUnmounted(() => {
|
|||||||
<!-- Log button removed as per user request -->
|
<!-- Log button removed as per user request -->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
<!-- Desktop Expansion Row -->
|
||||||
|
<tr v-if="expandedContainerId === container.id" class="expansion-row">
|
||||||
|
<td :colspan="6"> <!-- Colspan should match total number of columns -->
|
||||||
|
<div class="stats-container">
|
||||||
|
<!-- Stats content -->
|
||||||
|
<div v-if="isStatsLoading" class="stats-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> {{ t('dockerManager.stats.loading') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="statsError" class="stats-error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> {{ t('dockerManager.stats.error') }}: {{ statsError }}
|
||||||
|
</div>
|
||||||
|
<dl v-else-if="containerStats" class="stats-dl">
|
||||||
|
<dt>{{ t('dockerManager.stats.cpu') }}</dt>
|
||||||
|
<dd>{{ containerStats.CPUPerc }}</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.memory') }}</dt>
|
||||||
|
<dd>{{ containerStats.MemUsage }} ({{ containerStats.MemPerc }})</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.netIO') }}</dt>
|
||||||
|
<dd>{{ containerStats.NetIO }}</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.blockIO') }}</dt>
|
||||||
|
<dd>{{ containerStats.BlockIO }}</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.pids') }}</dt>
|
||||||
|
<dd>{{ containerStats.PIDs }}</dd>
|
||||||
|
<!-- Add more stats if available -->
|
||||||
|
</dl>
|
||||||
|
<div v-else class="stats-nodata">
|
||||||
|
{{ t('dockerManager.stats.noData') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="expandedContainerId === container.id" class="expansion-card-row">
|
||||||
|
<td colspan="1"> <!-- Only one cell in card view -->
|
||||||
|
<div class="stats-container card-stats-container">
|
||||||
|
|
||||||
|
<div v-if="isStatsLoading" class="stats-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> {{ t('dockerManager.stats.loading') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="statsError" class="stats-error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> {{ t('dockerManager.stats.error') }}: {{ statsError }}
|
||||||
|
</div>
|
||||||
|
<dl v-else-if="containerStats" class="stats-dl">
|
||||||
|
<dt>{{ t('dockerManager.stats.cpu') }}</dt>
|
||||||
|
<dd>{{ containerStats.CPUPerc }}</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.memory') }}</dt>
|
||||||
|
<dd>{{ containerStats.MemUsage }} ({{ containerStats.MemPerc }})</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.netIO') }}</dt>
|
||||||
|
<dd>{{ containerStats.NetIO }}</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.blockIO') }}</dt>
|
||||||
|
<dd>{{ containerStats.BlockIO }}</dd>
|
||||||
|
<dt>{{ t('dockerManager.stats.pids') }}</dt>
|
||||||
|
<dd>{{ containerStats.PIDs }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div v-else class="stats-nodata">
|
||||||
|
{{ t('dockerManager.stats.noData') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- End FIX -->
|
||||||
|
</template> <!-- End v-for template -->
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,24 +553,24 @@ onUnmounted(() => {
|
|||||||
padding: var(--base-padding, 1rem); /* Added padding */
|
padding: var(--base-padding, 1rem); /* Added padding */
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
.docker-table { /* Use specific class */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
.docker-table th, .docker-table td {
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.6rem 0.8rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--border-color-light, #eee);
|
border-bottom: 1px solid var(--border-color-light, #eee);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
td:first-child, th:first-child {
|
.docker-table td:first-child, .docker-table th:first-child {
|
||||||
white-space: normal;
|
/* white-space: normal; */ /* Let specific columns handle wrapping */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
th {
|
.docker-table th {
|
||||||
background-color: var(--header-bg-color);
|
background-color: var(--header-bg-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -427,7 +578,7 @@ th {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
.docker-table tbody tr:not(.expansion-row):not(.expansion-card-row):hover { /* Exclude expansion rows from hover */
|
||||||
background-color: var(--hover-bg-color, #f5f5f5);
|
background-color: var(--hover-bg-color, #f5f5f5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,8 +624,68 @@ tbody tr:hover {
|
|||||||
.action-btn.restart:not([disabled]):hover { color: var(--color-info, #17a2b8); }
|
.action-btn.restart:not([disabled]):hover { color: var(--color-info, #17a2b8); }
|
||||||
.action-btn.remove:not([disabled]):hover { color: var(--color-danger, #dc3545); }
|
.action-btn.remove:not([disabled]):hover { color: var(--color-danger, #dc3545); }
|
||||||
|
|
||||||
|
/* Styles for Expand Button */
|
||||||
|
.col-expand {
|
||||||
|
width: 30px; /* Fixed width for the button column */
|
||||||
|
padding: 0.6rem 0.4rem !important; /* Adjust padding */
|
||||||
|
text-align: center !important;
|
||||||
|
border-bottom: 1px solid var(--border-color-light, #eee); /* Match other cells */
|
||||||
|
}
|
||||||
|
.expand-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem;
|
||||||
|
font-size: 0.8em;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.expand-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for Expansion Row */
|
||||||
|
.expansion-row td {
|
||||||
|
padding: 0 !important; /* Remove padding from the cell itself */
|
||||||
|
border-bottom: 1px solid var(--border-color); /* Add a bottom border */
|
||||||
|
/* background-color: var(--item-expanded-bg, #f9f9f9); */ /* Optional background */
|
||||||
|
}
|
||||||
|
.stats-container {
|
||||||
|
padding: var(--base-padding, 1rem);
|
||||||
|
background-color: var(--item-expanded-bg, rgba(0,0,0,0.02)); /* Slightly different background */
|
||||||
|
}
|
||||||
|
.stats-loading, .stats-error, .stats-nodata {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stats-error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.stats-dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content auto; /* Label column and value column */
|
||||||
|
gap: 0.5rem 1rem; /* Row and column gap */
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.stats-dl dt {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.stats-dl dd {
|
||||||
|
grid-column: 2;
|
||||||
|
margin-left: 0;
|
||||||
|
font-family: var(--font-family-mono, monospace); /* Monospace for stats */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide card-specific expansion row by default */
|
||||||
|
.expansion-card-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* --- Responsive Table Styles using Container Query --- */
|
/* --- Responsive Table Styles using Container Query --- */
|
||||||
/* Target the container directly */
|
|
||||||
@container (max-width: 768px) {
|
@container (max-width: 768px) {
|
||||||
.responsive-table {
|
.responsive-table {
|
||||||
border: none; /* Remove table border */
|
border: none; /* Remove table border */
|
||||||
@@ -484,7 +695,7 @@ tbody tr:hover {
|
|||||||
display: none; /* Hide table header */
|
display: none; /* Hide table header */
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table tr {
|
.responsive-table tr:not(.expansion-card-row) { /* Target main rows only */
|
||||||
display: block; /* Make rows behave like blocks/cards */
|
display: block; /* Make rows behave like blocks/cards */
|
||||||
margin-bottom: 1rem; /* Space between cards */
|
margin-bottom: 1rem; /* Space between cards */
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -492,6 +703,8 @@ tbody tr:hover {
|
|||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
background-color: var(--item-bg-color, var(--app-bg-color)); /* Card background */
|
background-color: var(--item-bg-color, var(--app-bg-color)); /* Card background */
|
||||||
box-shadow: var(--shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
|
box-shadow: var(--shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
|
||||||
|
position: relative; /* Needed for absolute positioning of button */
|
||||||
|
padding-top: 2.5rem; /* Make space for the button at the top */
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table td {
|
.responsive-table td {
|
||||||
@@ -523,26 +736,40 @@ tbody tr:hover {
|
|||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide expand button column in card view */
|
||||||
|
.responsive-table .col-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position the expand button within the card */
|
||||||
|
.responsive-table tr:not(.expansion-card-row) .expand-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem; /* Position top-left */
|
||||||
|
font-size: 1em; /* Make it slightly larger */
|
||||||
|
z-index: 2; /* Ensure it's clickable */
|
||||||
|
display: inline-block; /* Ensure button is visible */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Adjust specific cells if needed */
|
/* Adjust specific cells if needed */
|
||||||
.responsive-table td:first-child { /* e.g., Name */
|
.responsive-table td:first-child { /* e.g., Name */
|
||||||
font-weight: 500; /* Make name slightly bolder */
|
font-weight: 500; /* Make name slightly bolder */
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table .action-buttons {
|
.responsive-table .action-buttons {
|
||||||
text-align: right; /* Keep buttons aligned right */
|
/* ... (existing action button styles) ... */
|
||||||
padding-left: 0; /* Remove padding override for actions */
|
display: flex;
|
||||||
padding-top: 0.8rem; /* Add some space above buttons */
|
justify-content: flex-end;
|
||||||
border-bottom: none; /* Ensure no border below buttons */
|
flex-wrap: wrap;
|
||||||
display: flex; /* Use flex for better button alignment */
|
gap: 0.5rem;
|
||||||
justify-content: flex-end; /* Align buttons to the right */
|
padding-left: 0; /* Reset padding */
|
||||||
flex-wrap: wrap; /* Allow buttons to wrap */
|
padding-top: 0.8rem;
|
||||||
gap: 0.5rem; /* Keep gap between buttons */
|
|
||||||
}
|
}
|
||||||
.responsive-table .action-buttons::before {
|
.responsive-table .action-buttons::before {
|
||||||
display: none; /* Hide label for action buttons cell */
|
display: none;
|
||||||
}
|
}
|
||||||
.responsive-table .action-buttons button {
|
.responsive-table .action-buttons button {
|
||||||
/* Ensure buttons don't get too small */
|
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +784,34 @@ tbody tr:hover {
|
|||||||
white-space: nowrap; /* Prevent text inside badge from wrapping */
|
white-space: nowrap; /* Prevent text inside badge from wrapping */
|
||||||
flex-shrink: 0; /* Prevent badge from shrinking */
|
flex-shrink: 0; /* Prevent badge from shrinking */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide the table-specific expansion row in card view */
|
||||||
|
.responsive-table .expansion-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Show the card-specific expansion row */
|
||||||
|
.responsive-table .expansion-card-row {
|
||||||
|
display: block; /* Ensure it's visible */
|
||||||
|
margin-bottom: 1rem; /* Match card spacing */
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-top: none; /* Remove top border as it connects to the card */
|
||||||
|
border-radius: 0 0 var(--border-radius-medium, 4px) var(--border-radius-medium, 4px); /* Round bottom corners */
|
||||||
|
background-color: var(--item-expanded-bg, rgba(0,0,0,0.02));
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
|
||||||
|
margin-top: -1rem; /* Pull it up slightly to connect visually */
|
||||||
|
}
|
||||||
|
.responsive-table .expansion-card-row td {
|
||||||
|
display: block;
|
||||||
|
padding: 0 !important; /* Remove default td padding */
|
||||||
|
text-align: left; /* Reset text align */
|
||||||
|
}
|
||||||
|
.responsive-table .card-stats-container {
|
||||||
|
padding: var(--base-padding, 1rem);
|
||||||
|
}
|
||||||
|
.responsive-table .expansion-card-row td::before {
|
||||||
|
display: none; /* No label needed for this row */
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
/* --- End Responsive Table Styles --- */
|
/* --- End Responsive Table Styles --- */
|
||||||
|
|
||||||
|
|||||||
@@ -813,6 +813,35 @@
|
|||||||
"start": "Start",
|
"start": "Start",
|
||||||
"remove": "Remove"
|
"remove": "Remove"
|
||||||
},
|
},
|
||||||
"waitingForSsh": "Waiting for SSH connection..."
|
"waitingForSsh": "Waiting for SSH connection...",
|
||||||
|
"stats": {
|
||||||
|
"loading": "Loading stats...",
|
||||||
|
"error": "Error loading stats",
|
||||||
|
"errorGeneric": "Could not load container statistics.",
|
||||||
|
"noData": "No stats data available.",
|
||||||
|
"cpu": "CPU %",
|
||||||
|
"memory": "Memory Usage / Limit",
|
||||||
|
"netIO": "Network I/O",
|
||||||
|
"blockIO": "Block I/O",
|
||||||
|
"pids": "PIDs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"testing": "Testing...",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"settings": "Settings",
|
||||||
|
"errorOccurred": "An error occurred.",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"close": "Close",
|
||||||
|
"remove": "Remove",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -813,6 +813,35 @@
|
|||||||
"start": "启动",
|
"start": "启动",
|
||||||
"remove": "移除"
|
"remove": "移除"
|
||||||
},
|
},
|
||||||
"waitingForSsh": "等待 SSH 连接..."
|
"waitingForSsh": "等待 SSH 连接...",
|
||||||
|
"stats": {
|
||||||
|
"loading": "正在加载状态...",
|
||||||
|
"error": "加载状态出错",
|
||||||
|
"errorGeneric": "无法加载容器统计信息。",
|
||||||
|
"noData": "无可用状态数据。",
|
||||||
|
"cpu": "CPU 使用率",
|
||||||
|
"memory": "内存使用 / 限制",
|
||||||
|
"netIO": "网络 I/O",
|
||||||
|
"blockIO": "磁盘 I/O",
|
||||||
|
"pids": "进程数"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "加载中...",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"testing": "测试中...",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"settings": "设置",
|
||||||
|
"errorOccurred": "发生错误。",
|
||||||
|
"dismiss": "忽略",
|
||||||
|
"close": "关闭",
|
||||||
|
"remove": "移除",
|
||||||
|
"expand": "展开",
|
||||||
|
"collapse": "折叠"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user