diff --git a/src/main/kotlin/app/termora/DialogWrapper.kt b/src/main/kotlin/app/termora/DialogWrapper.kt index e54b1d2..f1b1520 100644 --- a/src/main/kotlin/app/termora/DialogWrapper.kt +++ b/src/main/kotlin/app/termora/DialogWrapper.kt @@ -54,7 +54,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) { protected fun init() { - defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE + defaultCloseOperation = DISPOSE_ON_CLOSE initTitleBar() initEvents() @@ -158,12 +158,14 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) { openPopup = true } - val window = SwingUtilities.windowForComponent(c) - val windows = window.ownedWindows - for (w in windows) { - if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) { - openPopup = true - w.dispose() + val window = c as? Window ?: SwingUtilities.windowForComponent(c) + if (window != null) { + val windows = window.ownedWindows + for (w in windows) { + if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) { + openPopup = true + w.dispose() + } } } diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index 910b470..5e3a104 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -10,6 +10,8 @@ object Icons { val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") } val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") } val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") } + val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") } + val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") } val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") } val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") } val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") } @@ -26,6 +28,7 @@ object Icons { val empty by lazy { DynamicIcon("icons/empty.svg") } val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") } val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") } + val locate by lazy { DynamicIcon("icons/locate.svg", "icons/locate_dark.svg") } val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") } val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") } val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") } diff --git a/src/main/kotlin/app/termora/SSHTerminalTab.kt b/src/main/kotlin/app/termora/SSHTerminalTab.kt index cc38cb3..3946381 100644 --- a/src/main/kotlin/app/termora/SSHTerminalTab.kt +++ b/src/main/kotlin/app/termora/SSHTerminalTab.kt @@ -26,7 +26,6 @@ import org.apache.sshd.common.channel.ChannelListener import org.apache.sshd.common.session.Session import org.apache.sshd.common.session.SessionListener import org.apache.sshd.common.session.SessionListener.Event -import org.slf4j.LoggerFactory import java.nio.charset.StandardCharsets import java.util.* import javax.swing.JComponent @@ -36,7 +35,7 @@ import javax.swing.SwingUtilities class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { companion object { - private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java) + val SSHSession = DataKey(ClientSession::class) } private val mutex = Mutex() @@ -201,6 +200,13 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : } } + @Suppress("UNCHECKED_CAST") + override fun getData(dataKey: DataKey): T? { + if (dataKey == SSHSession) { + return sshSession as T? + } + return super.getData(dataKey) + } override fun stop() { if (mutex.tryLock()) { diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/SshClients.kt index 9838e61..53502ff 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/SshClients.kt @@ -2,10 +2,12 @@ package app.termora import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.terminal.TerminalSize +import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.SshClient import org.apache.sshd.client.channel.ChannelShell +import org.apache.sshd.client.channel.ClientChannelEvent import org.apache.sshd.client.config.hosts.HostConfigEntry import org.apache.sshd.client.config.hosts.HostConfigEntryResolver import org.apache.sshd.client.config.hosts.KnownHostEntry @@ -31,6 +33,7 @@ import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider import org.eclipse.jgit.transport.sshd.ProxyData import org.slf4j.LoggerFactory import java.awt.Window +import java.io.ByteArrayOutputStream import java.net.InetSocketAddress import java.net.Proxy import java.net.SocketAddress @@ -38,6 +41,7 @@ import java.nio.file.Path import java.nio.file.Paths import java.security.PublicKey import java.time.Duration +import java.util.* import java.util.concurrent.atomic.AtomicBoolean import javax.swing.JOptionPane import javax.swing.SwingUtilities @@ -75,6 +79,34 @@ object SshClients { } + /** + * 执行一个命令 + * + * @return first: exitCode , second: response + */ + fun execChannel( + session: ClientSession, + command: String + ): Pair { + + val baos = ByteArrayOutputStream() + val channel = session.createExecChannel(command) + channel.out = baos + + if (channel.open().verify(timeout).await(timeout)) { + channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout) + } + + IOUtils.closeQuietly(channel) + + if (channel.exitStatus == null) { + return Pair(-1, baos.toString()) + } + + return Pair(channel.exitStatus, baos.toString()) + + } + /** * 打开一个会话 */ diff --git a/src/main/kotlin/app/termora/TerminalTabbed.kt b/src/main/kotlin/app/termora/TerminalTabbed.kt index 2889167..632ab98 100644 --- a/src/main/kotlin/app/termora/TerminalTabbed.kt +++ b/src/main/kotlin/app/termora/TerminalTabbed.kt @@ -73,12 +73,17 @@ class TerminalTabbed( tabbedPane.addPropertyChangeListener("selectedIndex") { evt -> val oldIndex = evt.oldValue as Int val newIndex = evt.newValue as Int + if (oldIndex >= 0 && tabs.size > newIndex) { tabs[oldIndex].onLostFocus() } + if (newIndex >= 0 && tabs.size > newIndex) { tabs[newIndex].onGrabFocus() } + + SwingUtilities.invokeLater { tabbedPane.getComponentAt(newIndex).requestFocusInWindow() } + } // 选择变动 @@ -174,6 +179,9 @@ class TerminalTabbed( // 新的获取到焦点 tabs[tabbedPane.selectedIndex].onGrabFocus() + // 新的真正获取焦点 + tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow() + if (disposable) { Disposer.dispose(tab) } diff --git a/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt b/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt index 3e38293..c9f2408 100644 --- a/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt @@ -3,8 +3,10 @@ package app.termora.terminal.panel import app.termora.* import app.termora.actions.AnAction import app.termora.actions.AnActionEvent +import app.termora.actions.DataProvider import app.termora.actions.DataProviders import app.termora.terminal.DataKey +import app.termora.terminal.panel.vw.SystemInformationVisualWindow import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.ui.FlatRoundBorder import org.apache.commons.lang3.StringUtils @@ -70,6 +72,11 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable { initActions() } + override fun updateUI() { + super.updateUI() + border = FlatRoundBorder() + } + fun triggerShow() { if (!floatingToolbarEnable || closed) { return @@ -98,6 +105,9 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable { // Pin add(initPinActionButton()) + // 服务器信息 + add(initServerInfoActionButton()) + // 重连 add(initReconnectActionButton()) @@ -105,6 +115,34 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable { add(initCloseActionButton()) } + private fun initServerInfoActionButton(): JButton { + val btn = JButton(Icons.infoOutline) + btn.toolTipText = I18n.getString("termora.visual-window.system-information") + btn.addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + val tab = evt.getData(DataProviders.TerminalTab) ?: return + val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return + + if (tab !is SSHTerminalTab) { + terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported")) + return + } + + for (window in terminalPanel.getVisualWindows()) { + if (window is SystemInformationVisualWindow) { + terminalPanel.moveToFront(window) + return + } + } + + val visualWindowPanel = SystemInformationVisualWindow(tab, terminalPanel) + terminalPanel.addVisualWindow(visualWindowPanel) + + } + }) + return btn + } + private fun initPinActionButton(): JButton { val btn = JButton(Icons.pin) btn.isSelected = pinAction.isSelected diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt index 6bd93d5..39aeb01 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt @@ -6,7 +6,10 @@ import app.termora.actions.DataProvider import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviders import app.termora.terminal.* +import app.termora.terminal.panel.vw.VisualWindow +import app.termora.terminal.panel.vw.VisualWindowManager import com.formdev.flatlaf.util.SystemInfo +import org.apache.commons.lang3.ArrayUtils import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.SystemUtils import java.awt.* @@ -32,7 +35,7 @@ import kotlin.time.Duration.Companion.milliseconds class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) : - JPanel(BorderLayout()), DataProvider, Disposable { + JPanel(BorderLayout()), DataProvider, Disposable, VisualWindowManager { companion object { val Debug = DataKey(Boolean::class) @@ -46,6 +49,8 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect private val floatingToolbar = FloatingToolbarPanel() private val terminalDisplay = TerminalDisplay(this, terminal, terminalBlink) private val dataProviderSupport = DataProviderSupport() + private val layeredPane = TerminalLayeredPane() + private var visualWindows = emptyArray() val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal) @@ -118,8 +123,6 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect scrollBar.blockIncrement = 1 background = Color.black - - val layeredPane = TerminalLayeredPane() layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any) layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any) layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any) @@ -503,6 +506,23 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect height ) } + + is VisualWindow -> { + var location = c.location + val dimension = getDimension() + if (location.x > dimension.width) { + location = Point(dimension.width - c.preferredSize.width, location.y) + } + if (location.y > dimension.height) { + location = Point(location.x, dimension.height - c.preferredSize.height) + } + c.setBounds( + location.x, + location.y, + c.width, + c.height + ) + } } } } @@ -512,4 +532,42 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect override fun getData(dataKey: DataKey): T? { return dataProviderSupport.getData(dataKey) } + + override fun moveToFront(visualWindow: VisualWindow) { + if (visualWindow.isWindow()) { + visualWindow.getWindow()?.requestFocus() + return + } + layeredPane.moveToFront(visualWindow.getJComponent()) + } + + override fun getVisualWindows(): Array { + return visualWindows + } + + override fun addVisualWindow(visualWindow: VisualWindow) { + visualWindows = ArrayUtils.add(visualWindows, visualWindow) + layeredPane.add(visualWindow.getJComponent(), JLayeredPane.DRAG_LAYER as Any) + layeredPane.revalidate() + layeredPane.repaint() + } + + override fun removeVisualWindow(visualWindow: VisualWindow) { + rebaseVisualWindow(visualWindow) + visualWindows = ArrayUtils.removeElement(visualWindows, visualWindow) + } + + override fun rebaseVisualWindow(visualWindow: VisualWindow) { + layeredPane.remove(visualWindow.getJComponent()) + layeredPane.revalidate() + layeredPane.repaint() + requestFocusInWindow() + } + + override fun getDimension(): Dimension { + return Dimension( + terminalDisplay.size.width + padding.left + padding.right, + terminalDisplay.size.height + padding.bottom + padding.top + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseSelectionAdapter.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseSelectionAdapter.kt index e9ee945..694255f 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseSelectionAdapter.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseSelectionAdapter.kt @@ -42,6 +42,8 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane override fun mousePressed(e: MouseEvent) { + terminalPanel.requestFocusInWindow() + if (isMouseTracking) { return } @@ -77,7 +79,6 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane mousePressedPoint.y = e.y } - terminalPanel.requestFocusInWindow() // 如果只有 Shift 键按下,那么应该追加选中 if (selectionModel.hasSelection() && SwingUtilities.isLeftMouseButton(e) && e.modifiersEx == 1088) { diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/SmartProgressBar.kt b/src/main/kotlin/app/termora/terminal/panel/vw/SmartProgressBar.kt new file mode 100644 index 0000000..d2769a8 --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/SmartProgressBar.kt @@ -0,0 +1,31 @@ +package app.termora.terminal.panel.vw + +import com.formdev.flatlaf.extras.components.FlatProgressBar +import java.awt.Dimension +import javax.swing.UIManager + +class SmartProgressBar : FlatProgressBar() { + init { + preferredSize = Dimension(-1, UIManager.getInt("Table.rowHeight") - 6) + isStringPainted = true + maximum = 100 + minimum = 0 + } + + override fun setValue(n: Int) { + super.setValue(n) + + foreground = if (value < 60) { + UIManager.getColor("Component.accentColor") + } else if (value < 85) { + UIManager.getColor("Component.warning.focusedBorderColor") + } else { + UIManager.getColor("Component.error.focusedBorderColor") + } + } + + override fun updateUI() { + super.updateUI() + value = value + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt new file mode 100644 index 0000000..da4346d --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt @@ -0,0 +1,435 @@ +package app.termora.terminal.panel.vw + +import app.termora.* +import app.termora.actions.AnActionEvent +import app.termora.actions.DataProviders +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import org.apache.commons.lang3.StringUtils +import org.apache.sshd.client.session.ClientSession +import org.slf4j.LoggerFactory +import java.awt.BorderLayout +import java.util.* +import javax.swing.* +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.table.DefaultTableModel +import kotlin.time.Duration.Companion.milliseconds + + +class SystemInformationVisualWindow(private val tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : + VisualWindowPanel("SystemInformation", visualWindowManager) { + + companion object { + private val log = LoggerFactory.getLogger(SystemInformationVisualWindow::class.java) + } + + private val systemInformationPanel by lazy { SystemInformationPanel() } + + init { + initViews() + initEvents() + initVisualWindowPanel() + } + + + private fun initViews() { + title = I18n.getString("termora.visual-window.system-information") + add(systemInformationPanel, BorderLayout.CENTER) + } + + private fun initEvents() { + Disposer.register(this, systemInformationPanel) + Disposer.register(tab, this) + } + + override fun getWindowTitle(): String { + return tab.getTitle() + " - " + title + } + + override fun toggleWindow() { + val evt = AnActionEvent(tab.getJComponent(), StringUtils.EMPTY, EventObject(this)) + val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return + + super.toggleWindow() + + if (!isWindow()) { + terminalTabbedManager.setSelectedTerminalTab(tab) + } + } + + private inner class SystemInformationPanel : JPanel(BorderLayout()), Disposable { + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + private val cpuProgressBar = SmartProgressBar() + private val memoryProgressBar = SmartProgressBar() + private val swapProgressBar = SmartProgressBar() + private val mem = Mem() + private val cpu = CPU() + private val swap = Swap() + private val tableModel = object : DefaultTableModel() { + override fun isCellEditable(row: Int, column: Int): Boolean { + return false + } + } + + init { + initViews() + initEvents() + } + + + private fun initViews() { + add(createPanel(), BorderLayout.CENTER) + } + + private fun createPanel(): JComponent { + val formMargin = "4dlu" + var rows = 1 + val step = 2 + val p = JPanel(BorderLayout()) + val n = FormBuilder.create().debug(false).layout( + FormLayout( + "left:pref, $formMargin, default:grow", + "pref, $formMargin, pref, $formMargin, pref, $formMargin" + ) + ) + .add("CPU: ").xy(1, rows) + .add(cpuProgressBar).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.visual-window.system-information.mem")}: ").xy(1, rows) + .add(memoryProgressBar).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.visual-window.system-information.swap")}: ").xy(1, rows) + .add(swapProgressBar).xy(3, rows).apply { rows += step } + .build() + + val table = JTable(tableModel) + table.tableHeader.isEnabled = false + table.showVerticalLines = true + table.showHorizontalLines = true + table.fillsViewportHeight = true + + tableModel.addColumn(I18n.getString("termora.visual-window.system-information.filesystem")) + tableModel.addColumn(I18n.getString("termora.visual-window.system-information.used-total")) + + val centerRenderer = DefaultTableCellRenderer() + centerRenderer.setHorizontalAlignment(JLabel.CENTER) + table.columnModel.getColumn(1).cellRenderer = centerRenderer + + + p.add(n, BorderLayout.NORTH) + p.add(JScrollPane(table).apply { + border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor) + }, BorderLayout.CENTER) + p.border = BorderFactory.createEmptyBorder(6, 6, 6, 6) + + return p + } + + private fun initEvents() { + coroutineScope.launch { + while (coroutineScope.isActive) { + try { + refresh() + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } finally { + delay(1000.milliseconds) + } + } + } + } + + private suspend fun refresh() { + val session = tab.getData(SSHTerminalTab.SSHSession) ?: return + + try { + // 刷新 CPU 和 内存 + refreshCPUAndMem(session) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error("refreshCPUAndMem", e) + } + } + + try { + // 刷新磁盘 + refreshDisk(session) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error("refreshDisk", e) + } + } + + } + + private suspend fun refreshCPUAndMem(session: ClientSession) { + + // top + var pair = SshClients.execChannel(session, "top -bn1") + if (pair.first != 0) { + return + } + + val lines = pair.second.split(StringUtils.LF) + for (line in lines) { + val isCPU = line.startsWith("%Cpu(s):", true) + val isMibMem = line.startsWith("MiB Mem :", true) + val isKibMem = line.startsWith("KiB Mem :", true) + val isMibSwap = line.startsWith("MiB Swap:", true) + val isKibSwap = line.startsWith("KiB Swap:", true) + val unit = if (isKibSwap || isKibMem) 'K' else 'M' + + if (isCPU) { + val parts = StringUtils.removeStartIgnoreCase(line, "%Cpu(s):").split(",").map { it.trim() } + for (part in parts) { + if (part.endsWith("us")) { + cpu.us = StringUtils.removeEnd(part, "us").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("sy")) { + cpu.sy = StringUtils.removeEnd(part, "sy").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("ni")) { + cpu.ni = StringUtils.removeEnd(part, "ni").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("id")) { + cpu.id = StringUtils.removeEnd(part, "id").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("wa")) { + cpu.wa = StringUtils.removeEnd(part, "wa").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("hi")) { + cpu.hi = StringUtils.removeEnd(part, "hi").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("si")) { + cpu.si = StringUtils.removeEnd(part, "si").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("st")) { + cpu.st = StringUtils.removeEnd(part, "st").trim().toDoubleOrNull() ?: 0.0 + } + } + } else if (isMibMem || isKibMem) { + val parts = StringUtils.removeStartIgnoreCase(line, "${unit}iB Mem :") + .split(",") + .map { it.trim() } + for (part in parts) { + if (part.endsWith("total")) { + mem.total = StringUtils.removeEnd(part, "total").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("free")) { + mem.free = StringUtils.removeEnd(part, "free").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("used")) { + mem.used = StringUtils.removeEnd(part, "used").trim().toDoubleOrNull() ?: 0.0 + } else if (part.endsWith("buff/cache")) { + mem.buffCache = StringUtils.removeEnd(part, "buff/cache").trim().toDoubleOrNull() ?: 0.0 + } + } + + if (isKibMem) { + mem.total = mem.total / 1024.0 + mem.free = mem.free / 1024.0 + mem.used = mem.used / 1024.0 + mem.buffCache = mem.buffCache / 1024.0 + } + } else if (isMibSwap || isKibSwap) { + val parts = StringUtils.removeStartIgnoreCase(line, "${unit}iB Swap:") + .split(",") + .map { it.trim() } + + for (part in parts) { + if (part.contains("total")) { + swap.total = StringUtils.removeEnd(part, "total").trim().toDoubleOrNull() ?: 0.0 + } else if (part.contains("free")) { + swap.free = StringUtils.removeEnd(part, "free").trim().toDoubleOrNull() ?: 0.0 + } else if (part.contains("used.")) { + swap.used = part.substringBefore("used.").trim().toDoubleOrNull() ?: 0.0 + } + } + + if (isKibSwap) { + swap.total = swap.total / 1024.0 + swap.free = swap.free / 1024.0 + swap.used = swap.used / 1024.0 + } + } + } + + withContext(Dispatchers.Swing) { + cpuProgressBar.value = (100.0 - cpu.id).toInt() + memoryProgressBar.value = (mem.used / mem.total * 100.0).toInt() + memoryProgressBar.string = + "${formatBytes((mem.used * 1024 * 1024).toLong())} / ${formatBytes((mem.total * 1024 * 1024).toLong())}" + + swapProgressBar.value = (swap.used / swap.total * 100.0).toInt() + swapProgressBar.string = + "${formatBytes((swap.used * 1024 * 1024).toLong())} / ${formatBytes((swap.total * 1024 * 1024).toLong())}" + } + } + + private suspend fun refreshDisk(session: ClientSession) { + + // df -h + var pair = SshClients.execChannel(session, "df -B1") + if (pair.first != 0) { + return + } + + val disks = mutableListOf() + val lines = pair.second.split(StringUtils.LF) + for (line in lines) { + if (!line.startsWith("/dev/")) { + continue + } + + val parts = line.split("\\s+".toRegex()) + if (parts.size < 6) { + continue + } + + disks.add( + Disk( + filesystem = parts[0], + size = parts[1].toLong(), + used = parts[2].toLong(), + avail = parts[3].toLong(), + usePercentage = StringUtils.removeEnd(parts[4], "%").toIntOrNull() ?: 0, + mountedOn = parts[5], + ) + ) + } + + withContext(Dispatchers.Swing) { + while (tableModel.rowCount > 0) { + tableModel.removeRow(0) + } + + for (disk in disks) { + tableModel.addRow( + arrayOf( + " ${disk.filesystem}", + formatBytes(disk.used) + " / " + formatBytes(disk.size), + ) + ) + } + } + } + + override fun dispose() { + coroutineScope.cancel() + } + + } + + private data class Mem( + /** + * 总内存 + */ + var total: Double = 0.0, + /** + * 空闲内存 + */ + var free: Double = 0.0, + /** + * 已用内存 + */ + var used: Double = 0.0, + /** + * 缓存和缓冲区占用的内存 + */ + var buffCache: Double = 0.0, + ) + + private data class Swap( + /** + * 交换空间的总大小 + */ + var total: Double = 0.0, + /** + * 已使用的交换空间 + */ + var free: Double = 0.0, + /** + * 未使用的交换空间 + */ + var used: Double = 0.0, + ) + + private data class CPU( + /** + * 用户空间 CPU 占用时间百分比。 + * 该值表示 CPU 用于执行用户进程的时间比例。 + * 示例:如果系统中 CPU 用于执行用户程序的时间占总 CPU 时间的 40%,则该值为 40.0。 + */ + var us: Double = 0.0, + + /** + * 系统空间 CPU 占用时间百分比。 + * 该值表示 CPU 用于执行内核进程的时间比例。 + * 示例:如果内核进程占用 CPU 时间的 20%,则该值为 20.0。 + */ + var sy: Double = 0.0, + + /** + * 优先级调整过的进程(Nice)占用的 CPU 时间百分比。 + * 该值表示 CPU 用于执行“优先级较低的”进程的时间比例。 + * 示例:如果优先级调整过的进程占用 CPU 时间的 10%,则该值为 10.0。 + */ + var ni: Double = 0.0, + + /** + * CPU 空闲时间百分比。 + * 该值表示 CPU 在空闲状态下没有执行任何任务的时间比例。 + * 示例:如果 CPU 95% 处于空闲状态,该值为 95.0。 + */ + var id: Double = 0.0, + + /** + * I/O 等待时间百分比。 + * 该值表示 CPU 正在等待 I/O 操作完成的时间比例。 + * 示例:如果 CPU 由于 I/O 操作等待占用 5% 的时间,则该值为 5.0。 + */ + var wa: Double = 0.0, + + /** + * 硬件中断处理时间百分比。 + * 该值表示 CPU 用于处理中断请求的时间比例,通常由硬件触发。 + * 示例:如果 CPU 处理硬件中断占用 2% 的时间,则该值为 2.0。 + */ + var hi: Double = 0.0, + + /** + * 软件中断处理时间百分比。 + * 该值表示 CPU 用于处理由软件触发的中断的时间比例。 + * 示例:如果 CPU 处理软件中断占用 3% 的时间,则该值为 3.0。 + */ + var si: Double = 0.0, + + /** + * 虚拟化环境中的 CPU 抢占时间百分比。 + * 该值表示 CPU 在虚拟化环境中被其他虚拟机抢占的时间比例。 + * 示例:如果虚拟化环境中的 CPU 抢占占用 0.5% 的时间,则该值为 0.5。 + */ + var st: Double = 0.0, + ) + + private data class Disk( + var filesystem: String = StringUtils.EMPTY, + /** + * 总大小 + */ + var size: Long = 0L, + /** + * 已经使用的空间 + */ + var used: Long = 0L, + /** + * 可用空间 + */ + var avail: Long = 0L, + /** + * 已经使用的百分比 + */ + var usePercentage: Int = 0, + + /** + * 挂载点 + */ + var mountedOn: String = StringUtils.EMPTY + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindow.kt new file mode 100644 index 0000000..ddcd89b --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindow.kt @@ -0,0 +1,31 @@ +package app.termora.terminal.panel.vw + +import app.termora.Disposable +import java.awt.Window +import javax.swing.JComponent + +/** + * 虚拟窗口 + */ +interface VisualWindow : Disposable { + + /** + * 虚拟窗口内容 + */ + fun getJComponent(): JComponent + + /** + * 是否是独立窗口(独立成一个 Window) + */ + fun isWindow(): Boolean + + /** + * 如果是独立窗口,那么可以返回 + */ + fun getWindow(): Window? = null + + /** + * 切换独立模式 + */ + fun toggleWindow() +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowManager.kt b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowManager.kt new file mode 100644 index 0000000..73596ef --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowManager.kt @@ -0,0 +1,36 @@ +package app.termora.terminal.panel.vw + +import java.awt.Dimension + +interface VisualWindowManager { + + /** + * 将窗口移动到最前面 + */ + fun moveToFront(visualWindow: VisualWindow) + + /** + * 添加虚拟窗口 + */ + fun addVisualWindow(visualWindow: VisualWindow) + + /** + * 移除虚拟窗口 + */ + fun removeVisualWindow(visualWindow: VisualWindow) + + /** + * 变基,仅仅从 LayeredPane 移除,但是不从 [getVisualWindows] 中移除 + */ + fun rebaseVisualWindow(visualWindow: VisualWindow) + + /** + * 获取管理的所有窗口 + */ + fun getVisualWindows(): Array + + /** + * 获取管理器的宽高 + */ + fun getDimension(): Dimension +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt new file mode 100644 index 0000000..d637246 --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt @@ -0,0 +1,273 @@ +package app.termora.terminal.panel.vw + +import app.termora.* +import com.formdev.flatlaf.extras.components.FlatToolBar +import java.awt.* +import java.awt.event.* +import java.beans.PropertyChangeEvent +import java.beans.PropertyChangeListener +import javax.swing.* +import kotlin.math.max +import kotlin.math.min + + +open class VisualWindowPanel(protected val id: String, protected val visualWindowManager: VisualWindowManager) : + JPanel(BorderLayout()), VisualWindow { + + protected val properties get() = Database.getDatabase().properties + private val titleLabel = JLabel() + private val toolbar = FlatToolBar() + private val visualWindow = this + private val resizer = VisualWindowResizer(this) { !isWindow } + private var isWindow = false + set(value) { + val oldValue = field + field = value + firePropertyChange("isWindow", oldValue, value) + } + private var dialog: VisualWindowDialog? = null + private var oldBounds = Rectangle() + private var toggleWindowBtn = JButton(Icons.openInNewWindow) + private val closeWindowListener = object : WindowAdapter() { + override fun windowClosed(e: WindowEvent) { + close() + } + } + + + var title: String + set(value) { + titleLabel.text = value + } + get() = titleLabel.text + + + protected fun initVisualWindowPanel() { + initViews() + initEvents() + initToolBar() + } + + private fun initViews() { + border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor) + + val x = properties.getString("VisualWindow.${id}.location.x", "-1").toIntOrNull() ?: -1 + val y = properties.getString("VisualWindow.${id}.location.y", "-1").toIntOrNull() ?: -1 + val w = properties.getString("VisualWindow.${id}.location.width", "-1").toIntOrNull() ?: -1 + val h = properties.getString("VisualWindow.${id}.location.height", "-1").toIntOrNull() ?: -1 + + if (x >= 0 && y >= 0) { + setLocation(x, y) + } else { + setLocation(200, 200) + } + + if (w > 0 && h > 0) setSize(w, h) else setSize(400, 200) + + } + + private fun initEvents() { + val dragListener = DragListener() + toolbar.addMouseListener(dragListener) + toolbar.addMouseMotionListener(dragListener) + + // 监听全局事件 + Toolkit.getDefaultToolkit().addAWTEventListener(object : AWTEventListener { + override fun eventDispatched(event: AWTEvent) { + if (event is MouseEvent) { + if (event.id == MouseEvent.MOUSE_PRESSED) { + val c = event.component ?: return + if (SwingUtilities.isDescendingFrom(c, visualWindow)) { + visualWindowManager.moveToFront(visualWindow) + } + } + } + } + + }, MouseEvent.MOUSE_EVENT_MASK) + + // 阻止事件穿透 + addMouseListener(object : MouseAdapter() {}) + + toggleWindowBtn.addActionListener { toggleWindow() } + + addPropertyChangeListener("isWindow", object : PropertyChangeListener { + override fun propertyChange(evt: PropertyChangeEvent) { + if (isWindow) { + border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) + toggleWindowBtn.icon = Icons.openInToolWindow + } else { + border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor) + toggleWindowBtn.icon = Icons.openInNewWindow + } + } + }) + } + + private fun initToolBar() { + toolbar.add(JLabel(Icons.empty)) + toolbar.add(JLabel(Icons.empty)) + toolbar.add(Box.createHorizontalGlue()) + toolbar.add(titleLabel) + toolbar.add(Box.createHorizontalGlue()) + toolbar.add(toggleWindowBtn) + toolbar.add(JButton(Icons.close).apply { addActionListener { Disposer.dispose(visualWindow) } }) + toolbar.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor) + add(toolbar, BorderLayout.NORTH) + } + + override fun dispose() { + + val bounds = if (isWindow) oldBounds else bounds + properties.putString("VisualWindow.${id}.location.x", bounds.x.toString()) + properties.putString("VisualWindow.${id}.location.y", bounds.y.toString()) + properties.putString("VisualWindow.${id}.location.width", bounds.width.toString()) + properties.putString("VisualWindow.${id}.location.height", bounds.height.toString()) + + resizer.uninstall() + + this.close() + + } + + final override fun getJComponent(): JComponent { + return this + } + + override fun isWindow(): Boolean { + return isWindow + } + + override fun getWindow(): Window? { + return dialog + } + + protected open fun getWindowTitle(): String { + return id + } + + override fun toggleWindow() { + + if (isWindow) { + // 提前移除 dialog 的关闭事件 + dialog?.removeWindowListener(closeWindowListener) + } + + isWindow = !isWindow + dialog?.dispose() + dialog = null + + if (isWindow) { + oldBounds = bounds + // 变基 + visualWindowManager.rebaseVisualWindow(this) + + val dialog = VisualWindowDialog().apply { dialog = this } + dialog.addWindowListener(closeWindowListener) + dialog.isVisible = true + + } else { + bounds = oldBounds + visualWindowManager.removeVisualWindow(visualWindow) + visualWindowManager.addVisualWindow(visualWindow) + } + } + + private inner class DragListener() : MouseAdapter() { + private var startPoint: Point? = null + + override fun mousePressed(e: MouseEvent) { + if (isWindow) { + startPoint = null + return + } + startPoint = SwingUtilities.convertPoint(visualWindow, e.getPoint(), visualWindow.getParent()) + } + + override fun mouseDragged(e: MouseEvent) { + val startPoint = this.startPoint ?: return + val newPoint = SwingUtilities.convertPoint(visualWindow, e.getPoint(), visualWindow.getParent()) + val dimension = visualWindowManager.getDimension() + + val x = min( + visualWindow.getX() + (newPoint.x - startPoint.x), + dimension.width - visualWindow.width + ) + + val y = min( + visualWindow.getY() + (newPoint.y - startPoint.y), + dimension.height - visualWindow.height + ) + + visualWindow.setBounds(max(x, 0), max(y, 0), visualWindow.getWidth(), visualWindow.getHeight()) + + this.startPoint = newPoint + } + + override fun mouseReleased(e: MouseEvent) { + visualWindowManager.moveToFront(visualWindow) + } + + } + + + protected open fun close() { + SwingUtilities.invokeLater { + if (isWindow()) { + dialog?.dispose() + dialog = null + } + visualWindowManager.removeVisualWindow(visualWindow) + } + } + + private inner class VisualWindowDialog : DialogWrapper(null) { + + init { + isModal = false + controlsVisible = false + isResizable = true + title = getWindowTitle() + + initEvents() + + init() + + + val x = properties.getString("VisualWindow.${id}.dialog.location.x", "-1").toIntOrNull() ?: -1 + val y = properties.getString("VisualWindow.${id}.dialog.location.y", "-1").toIntOrNull() ?: -1 + val w = properties.getString("VisualWindow.${id}.dialog.location.width", "-1").toIntOrNull() ?: -1 + val h = properties.getString("VisualWindow.${id}.dialog.location.height", "-1").toIntOrNull() ?: -1 + + if (w > 0 && h > 0) setSize(w, h) else pack() + + if (x >= 0 && y >= 0) { + setLocation(x, y) + } else { + setLocationRelativeTo(null) + } + + + } + + private fun initEvents() { + Disposer.register(disposable, object : Disposable { + override fun dispose() { + properties.putString("VisualWindow.${id}.dialog.location.x", x.toString()) + properties.putString("VisualWindow.${id}.dialog.location.y", y.toString()) + properties.putString("VisualWindow.${id}.dialog.location.width", width.toString()) + properties.putString("VisualWindow.${id}.dialog.location.height", height.toString()) + } + }) + } + + + override fun createCenterPanel(): JComponent { + return getJComponent() + } + + override fun createSouthPanel(): JComponent? { + return null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowResizer.kt b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowResizer.kt new file mode 100644 index 0000000..1b248d8 --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowResizer.kt @@ -0,0 +1,48 @@ +package app.termora.terminal.panel.vw + +import com.formdev.flatlaf.ui.FlatWindowResizer +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent + +class VisualWindowResizer(resizeComp: JComponent, private val windowResizable: () -> Boolean = { true }) : + FlatWindowResizer(resizeComp) { + + override fun isWindowResizable(): Boolean { + return windowResizable.invoke() + } + + override fun getWindowBounds(): Rectangle { + return resizeComp.bounds + } + + override fun setWindowBounds(r: Rectangle) { + resizeComp.bounds = r + resizeComp.revalidate() + resizeComp.repaint() + } + + override fun limitToParentBounds(): Boolean { + return true + } + + override fun getParentBounds(): Rectangle { + return resizeComp.getParent().bounds + } + + override fun honorMinimumSizeOnResize(): Boolean { + return true + } + + override fun honorMaximumSizeOnResize(): Boolean { + return true + } + + override fun getWindowMinimumSize(): Dimension { + return resizeComp.minimumSize + } + + override fun getWindowMaximumSize(): Dimension { + return resizeComp.maximumSize + } +} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 4dd3b24..29345cb 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -342,6 +342,16 @@ termora.terminal.copied=Copied termora.terminal.channel-disconnected=Channel has been disconnected.\u0020 termora.terminal.channel-reconnect=Type {0} to reconnect. +# Visual Window +termora.visual-window.system-information=System information +termora.visual-window.system-information.mem=Mem +termora.visual-window.system-information.swap=Swap +termora.visual-window.system-information.filesystem=Filesystem +termora.visual-window.system-information.used-total=Used / Total + + +termora.floating-toolbar.not-supported=This action is not supported + # zmodem termora.addons.zmodem.skip=SKIP \ No newline at end of file diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 4708444..e41d2dc 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -328,5 +328,16 @@ termora.actions.open-new-window=打开新窗口 termora.actions.clear-screen=清除终端屏幕 termora.actions.switch-tab=切换到特定标签页 [1..9] + +# Visual Window +termora.visual-window.system-information=系统信息 +termora.visual-window.system-information.mem=内存 +termora.visual-window.system-information.swap=交换 +termora.visual-window.system-information.filesystem=文件系统 +termora.visual-window.system-information.used-total=使用 / 大小 + +termora.floating-toolbar.not-supported=不允许此操作 + + # zmodem termora.addons.zmodem.skip=跳过 \ No newline at end of file diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 64e1f52..fcfa305 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -309,6 +309,14 @@ termora.actions.open-new-window=開啟新視窗 termora.actions.clear-screen=清除終端機螢幕 termora.actions.switch-tab=切換到特定分頁 [1..9] +# Visual Window +termora.visual-window.system-information=系統訊息 +termora.visual-window.system-information.mem=內存 +termora.visual-window.system-information.swap=交換 +termora.visual-window.system-information.filesystem=檔案系統 +termora.visual-window.system-information.used-total=使用 / 大小 + +termora.floating-toolbar.not-supported=不允許此操作 # zmodem termora.addons.zmodem.skip=跳過 \ No newline at end of file diff --git a/src/main/resources/icons/locate.svg b/src/main/resources/icons/locate.svg new file mode 100644 index 0000000..2343fa2 --- /dev/null +++ b/src/main/resources/icons/locate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/locate_dark.svg b/src/main/resources/icons/locate_dark.svg new file mode 100644 index 0000000..a2ba607 --- /dev/null +++ b/src/main/resources/icons/locate_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/openInNewWindow.svg b/src/main/resources/icons/openInNewWindow.svg new file mode 100644 index 0000000..2f16f58 --- /dev/null +++ b/src/main/resources/icons/openInNewWindow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/openInNewWindow_dark.svg b/src/main/resources/icons/openInNewWindow_dark.svg new file mode 100644 index 0000000..fd51f10 --- /dev/null +++ b/src/main/resources/icons/openInNewWindow_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/openInToolWindow.svg b/src/main/resources/icons/openInToolWindow.svg new file mode 100644 index 0000000..c7ec768 --- /dev/null +++ b/src/main/resources/icons/openInToolWindow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/openInToolWindow_dark.svg b/src/main/resources/icons/openInToolWindow_dark.svg new file mode 100644 index 0000000..64b377d --- /dev/null +++ b/src/main/resources/icons/openInToolWindow_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/test/resources/sshd/Dockerfile b/src/test/resources/sshd/Dockerfile index 33bfb31..8299a6e 100644 --- a/src/test/resources/sshd/Dockerfile +++ b/src/test/resources/sshd/Dockerfile @@ -1,6 +1,6 @@ FROM linuxserver/openssh-server RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk update && apk add wget gcc g++ git make zsh htop inetutils-telnet && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ + && apk update && apk add wget gcc g++ git make zsh htop stress-ng inetutils-telnet && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ && tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \ && ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz