diff --git a/buildSrc/src/main/kotlin/DownloadJavadocsPlugin.kt b/buildSrc/src/main/kotlin/DownloadJavadocsPlugin.kt index 2a6d376e..93af3570 100644 --- a/buildSrc/src/main/kotlin/DownloadJavadocsPlugin.kt +++ b/buildSrc/src/main/kotlin/DownloadJavadocsPlugin.kt @@ -22,7 +22,7 @@ private fun legacyJavadoc(url: String) = JavadocUrl(url, noframe = true) class DownloadJavadocsPlugin : Plugin { private val toDownload = mapOf( "8.1" to listOf( - javadoc("https://files.inductiveautomation.com/sdk/javadoc/ignition81/8.1.27/"), + javadoc("https://files.inductiveautomation.com/sdk/javadoc/ignition81/8.1.29/"), javadoc("https://docs.oracle.com/en/java/javase/11/docs/api/"), legacyJavadoc("https://www.javadoc.io/static/org.python/jython-standalone/2.7.1/") ), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adaaa721..1cc8a71c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = flatlaf-jide = { group = "com.formdev", name = "flatlaf-jide-oss", version.ref = "flatlaf" } flatlaf-swingx = { group = "com.formdev", name = "flatlaf-swingx", version.ref = "flatlaf" } flatlaf-themes = { group = "com.formdev", name = "flatlaf-intellij-themes", version.ref = "flatlaf" } +flatlaf-fonts-roboto = { group = "com.formdev", name = "flatlaf-fonts-roboto", version = "2.137"} svgSalamander = { group = "com.formdev", name = "svgSalamander", version = "1.1.4" } jide-common = { group = "com.formdev", name = "jide-oss", version = "3.7.12" } swingx = { group = "org.swinglabs.swingx", name = "swingx-all", version = "1.6.5-1" } @@ -62,6 +63,7 @@ flatlaf = [ "flatlaf-jide", "flatlaf-swingx", "flatlaf-themes", + "flatlaf-fonts-roboto", ] kotest = [ "kotest-junit", diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt index ca7568a8..10b2c798 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt @@ -5,10 +5,13 @@ import com.formdev.flatlaf.extras.FlatAnimatedLafChange import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatUIDefaultsInspector import com.formdev.flatlaf.extras.components.FlatTextArea +import com.formdev.flatlaf.fonts.roboto.FlatRobotoFont import com.formdev.flatlaf.util.SystemInfo import io.github.inductiveautomation.kindling.core.ClipboardTool import io.github.inductiveautomation.kindling.core.CustomIconView import io.github.inductiveautomation.kindling.core.Kindling.Preferences.Advanced.Debug +import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.ChoosableEncodings +import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.DefaultEncoding import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.DefaultTool import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.HomeLocation import io.github.inductiveautomation.kindling.core.Kindling.Preferences.UI.ScaleFactor @@ -25,9 +28,12 @@ import io.github.inductiveautomation.kindling.utils.TabStrip import io.github.inductiveautomation.kindling.utils.chooseFiles import io.github.inductiveautomation.kindling.utils.getLogger import io.github.inductiveautomation.kindling.utils.jFrame +import io.github.inductiveautomation.kindling.utils.menuShortcutKeyMaskEx +import io.github.inductiveautomation.kindling.utils.traverseChildren import net.miginfocom.layout.PlatformDefaults import net.miginfocom.layout.UnitValue import net.miginfocom.swing.MigLayout +import java.awt.BorderLayout import java.awt.Desktop import java.awt.Dimension import java.awt.EventQueue @@ -39,22 +45,57 @@ import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.io.File +import java.nio.charset.Charset +import javax.swing.Box import javax.swing.JButton +import javax.swing.JComboBox import javax.swing.JFileChooser import javax.swing.JFrame +import javax.swing.JLabel import javax.swing.JMenu import javax.swing.JMenuBar import javax.swing.JPanel import javax.swing.KeyStroke +import javax.swing.SwingConstants import javax.swing.UIManager +import javax.swing.filechooser.FileFilter -class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { +class MainPanel : JPanel(MigLayout("ins 6, fill")) { private val fileChooser = JFileChooser(HomeLocation.currentValue.toFile()).apply { isMultiSelectionEnabled = true fileView = CustomIconView() + val encodingSelector = JComboBox(ChoosableEncodings).apply { + toolTipText = "Charset Encoding for Wrapper Logs" + selectedItem = DefaultEncoding.currentValue + addActionListener { + DefaultEncoding.currentValue = selectedItem as Charset + } + isEnabled = DefaultTool.currentValue.respectsEncoding + } + + traverseChildren().filterIsInstance().last().apply { + add(encodingSelector, 0) + add( + JLabel("Encoding: ", SwingConstants.RIGHT).apply { + verticalAlignment = SwingConstants.BOTTOM + }, + 0, + ) + } + Tool.byFilter.keys.forEach(this::addChoosableFileFilter) fileFilter = DefaultTool.currentValue.filter + addPropertyChangeListener(JFileChooser.FILE_FILTER_CHANGED_PROPERTY) { e -> + val relevantTool = Tool.byFilter[e.newValue as FileFilter] + encodingSelector.isEnabled = relevantTool?.respectsEncoding != false // null = 'all files', so enabled + } + + addActionListener { + if (selectedFile != null) { + HomeLocation.currentValue = selectedFile.parentFile.toPath() + } + } Theme.addChangeListener { updateUI() @@ -63,16 +104,30 @@ class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { private val openAction = Action( name = "Open...", - accelerator = KeyStroke.getKeyStroke(KeyEvent.VK_O, Toolkit.getDefaultToolkit().menuShortcutKeyMaskEx), + accelerator = KeyStroke.getKeyStroke(KeyEvent.VK_O, menuShortcutKeyMaskEx), ) { - fileChooser.chooseFiles(this)?.let { selectedFiles -> + fileChooser.chooseFiles(this@MainPanel)?.let { selectedFiles -> val selectedTool: Tool? = Tool.byFilter[fileChooser.fileFilter] openFiles(selectedFiles, selectedTool) } } - private val tabs = TabStrip() - private val openButton = JButton(openAction) + private val tabs = TabStrip().apply { + if (SystemInfo.isMacFullWindowContentSupported) { + // add padding component for MacOS window controls + leadingComponent = Box.createHorizontalStrut(70) + } + + trailingComponent = JPanel(BorderLayout()).apply { + add( + JButton(openAction).apply { + hideActionText = true + icon = FlatSVGIcon("icons/bx-plus.svg") + }, + BorderLayout.WEST, + ) + } + } private val debugMenu = JMenu("Debug").apply { add( @@ -116,7 +171,7 @@ class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { clipboardTool.open(clipString) } } else { - println("No string data found on clipboard") + LOGGER.info("No string data found on clipboard") } }, ) @@ -143,13 +198,6 @@ class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { * Opens a path in a tool (blocking). In the event of any error, opens an 'Error' tab instead. */ private fun openOrError(title: String, description: String, openFunction: () -> ToolPanel) { - synchronized(treeLock) { - val child = getComponent(0) - if (child == openButton) { - remove(openButton) - add(tabs, "dock center") - } - } runCatching { val toolPanel = openFunction() tabs.addTab(component = toolPanel, select = true) @@ -199,11 +247,7 @@ class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { } init { - if (empty) { - add(openButton, "dock center") - } else { - add(tabs, "dock center") - } + add(tabs, "dock center") Debug.addChangeListener { newValue -> debugMenu.isVisible = newValue @@ -217,15 +261,21 @@ class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { fun main(args: Array) { System.setProperty("apple.awt.application.name", "Kindling") System.setProperty("apple.laf.useScreenMenuBar", "true") + System.setProperty("apple.awt.application.appearance", "system") System.setProperty("flatlaf.uiScale", ScaleFactor.currentValue.toString()) EventQueue.invokeLater { lafSetup() - jFrame("Kindling", 1280, 800) { + jFrame( + title = "Kindling", + width = 1280, + height = 800, + embedContentIntoTitleBar = true, + ) { defaultCloseOperation = JFrame.EXIT_ON_CLOSE - val mainPanel = MainPanel(args.isEmpty()) + val mainPanel = MainPanel() add(mainPanel) jMenuBar = mainPanel.menuBar @@ -245,9 +295,15 @@ class MainPanel(empty: Boolean) : JPanel(MigLayout("ins 6, fill")) { } private fun lafSetup() { + FlatRobotoFont.install() + FlatLaf.setPreferredFontFamily(FlatRobotoFont.FAMILY) + FlatLaf.setPreferredLightFontFamily(FlatRobotoFont.FAMILY_LIGHT) + FlatLaf.setPreferredSemiboldFontFamily(FlatRobotoFont.FAMILY_SEMIBOLD) applyTheme(false) UIManager.getDefaults().apply { + put("Component.focusWidth", 0) + put("Component.innerfocusWidth", 1) put("ScrollBar.width", 16) put("TabbedPane.tabType", "card") put("MenuItem.minimumIconSize", Dimension()) // https://github.com/JFormDesigner/FlatLaf/issues/328 diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt index 61b1dab5..f4bc9db2 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt @@ -36,18 +36,15 @@ class AlarmJournalData( val body by lazy { data.properties.map { property -> - println(property.name) "${property.name} (${property.type.simpleName}) = ${data.getOrDefault(property)}" } } - fun toDetail(): Detail { - return Detail( - title = "Alarm Journal Data", - details = details, - body = body, - ) - } + fun toDetail() = Detail( + title = "Alarm Journal Data", + details = details, + body = body, + ) companion object { @JvmStatic diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Detail.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Detail.kt index 48db2359..e84b6e12 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Detail.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Detail.kt @@ -5,7 +5,7 @@ import io.github.inductiveautomation.kindling.core.Detail.BodyLine data class Detail( val title: String, val message: String? = null, - val details: Map = emptyMap(), + val details: Map = emptyMap(), val body: List = emptyList(), ) { data class BodyLine(val text: String, val link: String? = null) @@ -14,7 +14,7 @@ data class Detail( operator fun invoke( title: String, message: String? = null, - details: Map = emptyMap(), + details: Map = emptyMap(), body: List = emptyList(), ) = Detail( title, diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt index b095aa2f..4c87d89c 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt @@ -5,6 +5,7 @@ import com.formdev.flatlaf.themes.FlatMacLightLaf import com.formdev.flatlaf.util.SystemInfo import io.github.inductiveautomation.kindling.core.Preference.Companion.PreferenceCheckbox import io.github.inductiveautomation.kindling.core.Preference.Companion.preference +import io.github.inductiveautomation.kindling.utils.CharsetSerializer import io.github.inductiveautomation.kindling.utils.PathSerializer import io.github.inductiveautomation.kindling.utils.PathSerializer.serializedForm import io.github.inductiveautomation.kindling.utils.ThemeSerializer @@ -22,6 +23,7 @@ import kotlinx.serialization.json.encodeToStream import org.jdesktop.swingx.JXTextField import java.awt.Image import java.awt.Toolkit +import java.nio.charset.Charset import java.nio.file.Path import java.util.Vector import javax.swing.JComboBox @@ -37,13 +39,15 @@ import kotlin.io.path.outputStream import kotlin.time.Duration.Companion.seconds import io.github.inductiveautomation.kindling.core.Theme.Companion as KindlingTheme -object Kindling { +data object Kindling { val frameIcon: Image = Toolkit.getDefaultToolkit().getImage(Kindling::class.java.getResource("/icons/kindling.png")) - object Preferences { - object General : PreferenceCategory { + const val SECONDARY_ACTION_ICON_SCALE = 0.75F + + data object Preferences { + data object General : PreferenceCategory { val HomeLocation: Preference = preference( - name = "Home Location", + name = "Browse Location", description = "The default path to start looking for files.", default = Path(System.getProperty("user.home"), "Downloads"), serializer = PathSerializer, @@ -67,7 +71,7 @@ object Kindling { ) val DefaultTool: Preference = preference( - "Default Tool", + name = "Default Tool", description = "The default tool to use when invoking the file selector", default = Tool.tools.first(), serializer = ToolSerializer, @@ -95,6 +99,33 @@ object Kindling { }, ) + val ChoosableEncodings = arrayOf( + Charsets.UTF_8, + Charsets.ISO_8859_1, + Charsets.US_ASCII, + ) + + val DefaultEncoding: Preference = preference( + name = "Encoding", + description = "The default encoding to use when loading text files", + default = if (SystemInfo.isWindows) Charsets.ISO_8859_1 else Charsets.UTF_8, + serializer = CharsetSerializer, + editor = { + JComboBox(ChoosableEncodings).apply { + selectedItem = currentValue + + configureCellRenderer { _, value, _, _, _ -> + text = value?.displayName() + toolTipText = value?.displayName() + } + + addActionListener { + currentValue = selectedItem as Charset + } + } + }, + ) + val ShowFullLoggerNames: Preference = preference( name = "Logger Names", default = false, @@ -112,10 +143,11 @@ object Kindling { ) override val displayName: String = "General" + override val key: String = "general" override val preferences: List> = listOf(HomeLocation, DefaultTool, ShowFullLoggerNames, UseHyperlinks) } - object UI : PreferenceCategory { + data object UI : PreferenceCategory { val Theme: Preference = preference( name = "Theme", default = KindlingTheme.themes.getValue(if (SystemInfo.isMacOS) FlatMacLightLaf.NAME else FlatLightLaf.NAME), @@ -145,10 +177,11 @@ object Kindling { ) override val displayName: String = "UI" + override val key: String = "ui" override val preferences: List> = listOf(Theme, ScaleFactor) } - object Advanced : PreferenceCategory { + data object Advanced : PreferenceCategory { val Debug: Preference = preference( name = "Debug Mode", description = null, @@ -163,7 +196,7 @@ object Kindling { default = LinkHandlingStrategy.OpenInBrowser, serializer = LinkHandlingStrategy.serializer(), editor = { - JComboBox(LinkHandlingStrategy.values()).apply { + JComboBox(Vector(LinkHandlingStrategy.entries)).apply { selectedItem = currentValue configureCellRenderer { _, value, _, _, _ -> @@ -178,6 +211,7 @@ object Kindling { ) override val displayName: String = "Advanced" + override val key: String = "advanced" override val preferences: List> = listOf(Debug, HyperlinkStrategy) } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt index 2fc431b9..08dbcb44 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt @@ -23,7 +23,6 @@ import javax.swing.border.MatteBorder interface PreferenceCategory { val displayName: String val key: String - get() = displayName.lowercase().filter(Char::isJavaIdentifierStart) val preferences: List> } @@ -35,7 +34,7 @@ class Preference( val requiresRestart: Boolean = false, private val default: T, val serializer: KSerializer, - createEditor: Preference.() -> JComponent, + createEditor: (Preference.() -> JComponent)?, ) { val key: String = name.lowercase().filter(Char::isJavaIdentifierStart) @@ -54,7 +53,7 @@ class Preference( listeners.add(listener) } - val editor: JComponent by lazy { createEditor(this) } + val editor: JComponent? by lazy { createEditor?.invoke(this) } companion object { @Suppress("FunctionName") @@ -67,13 +66,23 @@ class Preference( } } + /** + * Creates a new [Preference] instance, automatically contained within the current [PreferenceCategory]. + * + * [description] is optional, but should be provided or set on the editor + * component (e.g. [PreferenceCheckbox]). + * + * [serializer] can be omitted if the type is natively serializable, but is otherwise required. + * + * [editor] should return null if this is not a 'user-facing' preference. + */ inline fun PreferenceCategory.preference( name: String, description: String? = null, requiresRestart: Boolean = false, default: T, serializer: KSerializer = serializer(), - noinline editor: Preference.() -> JComponent, + noinline editor: (Preference.() -> JComponent)?, ): Preference = Preference( name = name, category = this, @@ -109,20 +118,23 @@ val preferencesEditor by lazy { isCollapsed = category.displayName == "Advanced" for (preference in category.preferences) { - add( - StyledLabel { - add(preference.name, Font.BOLD) - if (preference.requiresRestart) { - add(" Requires restart", "superscript") - } - if (preference.description != null) { - add("\n") - add(preference.description) - } - }, - "grow, wrap, gapy 0", - ) - add(preference.editor, "grow, wrap, gapy 0") + val editor = preference.editor + if (editor != null) { + add( + StyledLabel { + add(preference.name, Font.BOLD) + if (preference.requiresRestart) { + add(" Requires restart", "superscript") + } + if (preference.description != null) { + add("\n") + add(preference.description) + } + }, + "grow, wrap, gapy 0", + ) + add(editor, "grow, wrap, gapy 0") + } } } add(categoryPane) diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Tool.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Tool.kt index 35e5ebc5..e86a10c1 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Tool.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Tool.kt @@ -17,6 +17,8 @@ interface Tool { val description: String val icon: FlatSVGIcon val extensions: List + val respectsEncoding: Boolean + get() = false fun open(path: Path): ToolPanel diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt index 32b10028..b141c6f4 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt @@ -29,7 +29,7 @@ abstract class ToolPanel( protected fun exportMenu(defaultFileName: String = "", modelSupplier: () -> TableModel): JMenu = JMenu("Export").apply { - for (format in ExportFormat.values()) { + for (format in ExportFormat.entries) { add( Action("Export as ${format.extension.uppercase()}") { exportFileChooser.apply { diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt index a13d8cb8..a6b59e10 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt @@ -1,13 +1,14 @@ package io.github.inductiveautomation.kindling.idb import com.formdev.flatlaf.extras.FlatSVGIcon +import com.formdev.flatlaf.extras.components.FlatTabbedPane.TabType import io.github.inductiveautomation.kindling.core.Tool import io.github.inductiveautomation.kindling.core.ToolPanel import io.github.inductiveautomation.kindling.idb.generic.GenericView import io.github.inductiveautomation.kindling.idb.metrics.MetricsView -import io.github.inductiveautomation.kindling.log.Level import io.github.inductiveautomation.kindling.log.LogPanel -import io.github.inductiveautomation.kindling.log.SystemLogsEvent +import io.github.inductiveautomation.kindling.log.MDC +import io.github.inductiveautomation.kindling.log.SystemLogEvent import io.github.inductiveautomation.kindling.utils.SQLiteConnection import io.github.inductiveautomation.kindling.utils.TabStrip import io.github.inductiveautomation.kindling.utils.toList @@ -26,6 +27,9 @@ class IdbView(val path: Path) : ToolPanel() { private val tabs = TabStrip().apply { trailingComponent = null isTabsClosable = false + tabType = TabType.underlined + tabHeight = 16 + isHideTabAreaWithOneTab = true } init { @@ -40,7 +44,7 @@ class IdbView(val path: Path) : ToolPanel() { ) var addedTabs = 0 - for (tool in IdbTool.values()) { + for (tool in IdbTool.entries) { if (tool.supports(tables)) { tabs.addLazyTab( tabName = tool.name, @@ -91,7 +95,7 @@ enum class IdbTool { ) }.groupBy(keySelector = { it.first }, valueTransform = { it.second }) - val mdcKeys: Map> = connection.prepareStatement( + val mdcKeys: Map> = connection.prepareStatement( //language=sql """ SELECT @@ -104,17 +108,17 @@ enum class IdbTool { event_id """.trimIndent(), ).executeQuery() - .toList { resultSet -> - Triple( - resultSet.getInt("event_id"), - resultSet.getString("mapped_key"), - resultSet.getString("mapped_value"), - ) - }.groupingBy { it.first } - .aggregateTo(mutableMapOf>()) { _, accumulator, element, _ -> - val acc = accumulator ?: mutableMapOf() - acc[element.second] = element.third ?: "null" - acc + .use { resultSet -> + buildMap> { + while (resultSet.next()) { + val key = resultSet.getInt("event_id") + val valueList = getOrPut(key) { mutableListOf() } + valueList += MDC( + resultSet.getString("mapped_key"), + resultSet.getString("mapped_value"), + ) + } + } } val events = connection.prepareStatement( @@ -130,21 +134,22 @@ enum class IdbTool { FROM logging_event ORDER BY - event_id + timestmp """.trimIndent(), ).executeQuery() .toList { resultSet -> val eventId = resultSet.getInt("event_id") - SystemLogsEvent( + SystemLogEvent( timestamp = Instant.ofEpochMilli(resultSet.getLong("timestmp")), message = resultSet.getString("formatted_message"), logger = resultSet.getString("logger_name"), thread = resultSet.getString("thread_name"), - level = Level.valueOf(resultSet.getString("level_string")), + level = enumValueOf(resultSet.getString("level_string")), mdc = mdcKeys[eventId].orEmpty(), stacktrace = stackTraces[eventId].orEmpty(), ) } + return LogPanel(events) } }, diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/DBMetaDataTree.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/DBMetaDataTree.kt index 85edcd5e..d389b6c7 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/DBMetaDataTree.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/DBMetaDataTree.kt @@ -4,6 +4,7 @@ import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.components.FlatTree import com.jidesoft.swing.StyledLabelBuilder import com.jidesoft.swing.TreeSearchable +import io.github.inductiveautomation.kindling.core.Kindling.SECONDARY_ACTION_ICON_SCALE import io.github.inductiveautomation.kindling.utils.derive import io.github.inductiveautomation.kindling.utils.treeCellRenderer import java.awt.Font @@ -65,10 +66,10 @@ class DBMetaDataTree(treeModel: TreeModel) : FlatTree() { } companion object { - private val TABLE_ICON = FlatSVGIcon("icons/bx-table.svg").derive(0.75F) + private val TABLE_ICON = FlatSVGIcon("icons/bx-table.svg").derive(SECONDARY_ACTION_ICON_SCALE) private val TABLE_ICON_SELECTED = TABLE_ICON.derive { UIManager.getColor("Tree.selectionForeground") } - private val COLUMN_ICON = FlatSVGIcon("icons/bx-column.svg").derive(0.75F) + private val COLUMN_ICON = FlatSVGIcon("icons/bx-column.svg").derive(SECONDARY_ACTION_ICON_SCALE) private val COLUMN_ICON_SELECTED = COLUMN_ICON.derive { UIManager.getColor("Tree.selectionForeground") } } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/GenericView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/GenericView.kt index 3431b125..62a42fea 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/GenericView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/GenericView.kt @@ -5,10 +5,10 @@ import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.attachPopupMenu import io.github.inductiveautomation.kindling.utils.javaType +import io.github.inductiveautomation.kindling.utils.menuShortcutKeyMaskEx import io.github.inductiveautomation.kindling.utils.toList import net.miginfocom.swing.MigLayout import java.awt.Dimension -import java.awt.Toolkit import java.awt.event.KeyEvent import java.sql.Connection import java.sql.JDBCType @@ -118,7 +118,7 @@ class GenericView(connection: Connection) : ToolPanel("ins 0, fill, hidemode 3") private val results = ResultsPanel() init { - val ctrlEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, Toolkit.getDefaultToolkit().menuShortcutKeyMaskEx) + val ctrlEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, menuShortcutKeyMaskEx) getInputMap(WHEN_IN_FOCUSED_WINDOW).put(ctrlEnter, "execute") actionMap.put("execute", execute) diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/internal/DetailsIcon.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/internal/DetailsIcon.kt index e9a6729a..d9b43541 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/internal/DetailsIcon.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/internal/DetailsIcon.kt @@ -1,6 +1,7 @@ package io.github.inductiveautomation.kindling.internal import com.formdev.flatlaf.extras.FlatSVGIcon +import io.github.inductiveautomation.kindling.core.Kindling.SECONDARY_ACTION_ICON_SCALE import org.jdesktop.swingx.JXTable import org.jdesktop.swingx.decorator.HighlighterFactory import java.awt.event.MouseAdapter @@ -41,6 +42,6 @@ class DetailsIcon(details: Map) : JLabel(detailsIcon) { } companion object { - private val detailsIcon = FlatSVGIcon("icons/bx-search.svg").derive(0.75F) + private val detailsIcon = FlatSVGIcon("icons/bx-search.svg").derive(SECONDARY_ACTION_ICON_SCALE) } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/DurationUnit.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/DurationUnit.kt new file mode 100644 index 00000000..45814d1a --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/DurationUnit.kt @@ -0,0 +1,33 @@ +package io.github.inductiveautomation.kindling.log + +import java.time.Duration +import java.time.temporal.Temporal +import java.time.temporal.TemporalUnit + +// Credit to https://stackoverflow.com/a/66203968 +class DurationUnit(private val duration: Duration) : TemporalUnit { + init { + require(!(duration.isZero || duration.isNegative)) { "Duration may not be zero or negative" } + } + + override fun getDuration(): Duration = duration + override fun isDurationEstimated(): Boolean = duration.seconds >= SECONDS_PER_DAY + override fun isDateBased(): Boolean = duration.nano == 0 && duration.seconds % SECONDS_PER_DAY == 0L + override fun isTimeBased(): Boolean = duration.seconds < SECONDS_PER_DAY && NANOS_PER_DAY % duration.toNanos() == 0L + + @Suppress("UNCHECKED_CAST") + override fun addTo(temporal: R, amount: Long): R = + duration.multipliedBy(amount).addTo(temporal) as R + + override fun between(temporal1Inclusive: Temporal, temporal2Exclusive: Temporal): Long { + return Duration.between(temporal1Inclusive, temporal2Exclusive).dividedBy(duration) + } + + override fun toString(): String = duration.toString() + + companion object { + private const val SECONDS_PER_DAY = 86_400 + private const val NANOS_PER_SECOND = 1_000_000_000L + private const val NANOS_PER_DAY = NANOS_PER_SECOND * SECONDS_PER_DAY + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt new file mode 100644 index 00000000..b4373f25 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt @@ -0,0 +1,57 @@ +package io.github.inductiveautomation.kindling.log + +import io.github.inductiveautomation.kindling.utils.Action +import io.github.inductiveautomation.kindling.utils.Column +import io.github.inductiveautomation.kindling.utils.FilterList +import io.github.inductiveautomation.kindling.utils.FilterModel +import io.github.inductiveautomation.kindling.utils.FlatScrollPane +import io.github.inductiveautomation.kindling.utils.add +import io.github.inductiveautomation.kindling.utils.getAll +import javax.swing.JComponent +import javax.swing.JPopupMenu +import javax.swing.event.EventListenerList + +internal class LevelPanel(rawData: List) : LogFilterPanel { + private val filterList: FilterList = FilterList() + override val component: JComponent = FlatScrollPane(filterList) + + private val listenerList = EventListenerList() + + init { + filterList.setModel(FilterModel(rawData.groupingBy { it.level?.name }.eachCount())) + filterList.selectAll() + + filterList.checkBoxListSelectionModel.addListSelectionListener { e -> + if (!e.valueIsAdjusting) { + listenerList.getAll().forEach(FilterChangeListener::filterChanged) + } + } + } + + override val tabName: String = "Level" + override fun isFilterApplied() = filterList.checkBoxListSelectedValues.size != filterList.model.size - 1 + override fun filter(event: LogEvent): Boolean = event.level?.name in filterList.checkBoxListSelectedValues + override fun addFilterChangeListener(listener: FilterChangeListener) { + listenerList.add(listener) + } + + override fun customizePopupMenu(menu: JPopupMenu, column: Column, event: LogEvent) { + val level = event.level + if ((column == WrapperLogColumns.Level || column == SystemLogColumns.Level) && level != null) { + val levelIndex = filterList.model.indexOf(level.name) + menu.add( + Action("Show only $level events") { + filterList.checkBoxListSelectedIndex = levelIndex + filterList.ensureIndexIsVisible(levelIndex) + }, + ) + menu.add( + Action("Exclude $level events") { + filterList.removeCheckBoxListSelectedIndex(levelIndex) + }, + ) + } + } + + override fun reset() = filterList.selectAll() +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt index e73d826d..a3a495cc 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt @@ -1,27 +1,34 @@ package io.github.inductiveautomation.kindling.log +import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.ui.FlatScrollBarUI import io.github.inductiveautomation.kindling.core.Detail.BodyLine import io.github.inductiveautomation.kindling.core.DetailsPane +import io.github.inductiveautomation.kindling.core.Kindling.Preferences.Advanced.Debug import io.github.inductiveautomation.kindling.core.Kindling.Preferences.Advanced.HyperlinkStrategy import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.ShowFullLoggerNames import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.UseHyperlinks import io.github.inductiveautomation.kindling.core.LinkHandlingStrategy +import io.github.inductiveautomation.kindling.core.ToolOpeningException import io.github.inductiveautomation.kindling.core.ToolPanel -import io.github.inductiveautomation.kindling.log.LogViewer.SelectedTimeZone import io.github.inductiveautomation.kindling.log.LogViewer.ShowDensity +import io.github.inductiveautomation.kindling.log.LogViewer.TimeStampFormatter +import io.github.inductiveautomation.kindling.utils.Action +import io.github.inductiveautomation.kindling.utils.Column import io.github.inductiveautomation.kindling.utils.EDT_SCOPE import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.MajorVersion import io.github.inductiveautomation.kindling.utils.ReifiedJXTable +import io.github.inductiveautomation.kindling.utils.attachPopupMenu import io.github.inductiveautomation.kindling.utils.configureCellRenderer -import io.github.inductiveautomation.kindling.utils.getValue +import io.github.inductiveautomation.kindling.utils.isSortedBy import io.github.inductiveautomation.kindling.utils.toBodyLine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.miginfocom.swing.MigLayout import org.jdesktop.swingx.JXSearchField +import org.jdesktop.swingx.table.ColumnControlButton import java.awt.Dimension import java.awt.Graphics import java.awt.Graphics2D @@ -30,15 +37,16 @@ import java.awt.RenderingHints import java.awt.geom.AffineTransform import java.time.Duration import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.temporal.Temporal -import java.time.temporal.TemporalUnit +import java.util.EventListener +import java.util.Vector +import javax.swing.AbstractAction import javax.swing.Icon +import javax.swing.JCheckBox import javax.swing.JComboBox import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.JPopupMenu import javax.swing.JScrollBar import javax.swing.JSplitPane import javax.swing.ListSelectionModel @@ -50,21 +58,36 @@ import kotlin.properties.Delegates import io.github.inductiveautomation.kindling.core.Detail as DetailEvent class LogPanel( + /** + * Pass a **sorted** list of LogEvents, in ascending order. + */ private val rawData: List, ) : ToolPanel("ins 0, fill, hidemode 3") { - private val totalRows: Int = rawData.size + init { + if (rawData.isEmpty()) { + throw ToolOpeningException("Opening an empty log file is pointless") + } + if (!rawData.isSortedBy(LogEvent::timestamp)) { + throw ToolOpeningException("Input data must be sorted by timestamp, ascending") + } + } - var dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss:SSS") - .withZone(ZoneId.systemDefault()) + private val totalRows: Int = rawData.size private val densityDisplay = GroupingScrollBar() - val header = Header(totalRows) + private val header = Header(totalRows) + + private val columnList = if (rawData.first() is SystemLogEvent) { + SystemLogColumns + } else { + WrapperLogColumns + } val table = run { val initialModel = createModel(rawData) - ReifiedJXTable(initialModel, initialModel.columns).apply { - setSortOrder("Timestamp", SortOrder.ASCENDING) + ReifiedJXTable(initialModel, columnList).apply { + setSortOrder(columnList[columnList.Timestamp], SortOrder.ASCENDING) } } @@ -72,15 +95,16 @@ class LogPanel( verticalScrollBar = densityDisplay } + private val sidebar = Sidebar(rawData) + private val details = DetailsPane() - private val sidebar = LoggerNamesPanel(rawData) - private val filters: List<(LogEvent) -> Boolean> = buildList { + private val filters: List = buildList { + for (panel in sidebar.filterPanels) { + add(panel) + } add { event -> - event.logger in sidebar.list.checkBoxListSelectedIndices - .map { sidebar.list.model.getElementAt(it) } - .filterIsInstance() - .mapTo(mutableSetOf()) { it.name } + !header.showOnlyMarked.selected || event.marked } add { event -> val text = header.search.text @@ -88,17 +112,12 @@ class LogPanel( true } else { when (event) { - is SystemLogsEvent -> { - text in event.message || - event.logger.contains(text, ignoreCase = true) || - event.thread.contains(text, ignoreCase = true) || - event.stacktrace.any { stacktrace -> stacktrace.contains(text, ignoreCase = true) } + is SystemLogEvent -> { + text in event.message || event.logger.contains(text, ignoreCase = true) || event.thread.contains(text, ignoreCase = true) || event.stacktrace.any { stacktrace -> stacktrace.contains(text, ignoreCase = true) } } is WrapperLogEvent -> { - text in event.message || - event.logger.contains(text, ignoreCase = true) || - event.stacktrace.any { stacktrace -> stacktrace.contains(text, ignoreCase = true) } + text in event.message || event.logger.contains(text, ignoreCase = true) || event.stacktrace.any { stacktrace -> stacktrace.contains(text, ignoreCase = true) } } } } @@ -107,9 +126,19 @@ class LogPanel( private fun updateData() { BACKGROUND.launch { - val filteredData = rawData.filter { event -> - filters.all { filter -> filter(event) } + val filteredData = if (Debug.currentValue) { + // use a less efficient, but more debuggable, filtering sequence + filters.fold(rawData) { acc, logFilter -> + acc.filter(logFilter::filter).also { + println("${it.size} left after $logFilter") + } + } + } else { + rawData.filter { event -> + filters.all { filter -> filter.filter(event) } + } } + EDT_SCOPE.launch { table.model = createModel(filteredData) } @@ -117,12 +146,13 @@ class LogPanel( } @Suppress("UNCHECKED_CAST") - private fun createModel(rawData: List) = when (rawData.firstOrNull()) { - is WrapperLogEvent -> LogsModel(rawData as List, WrapperLogColumns(this)) - is SystemLogsEvent -> LogsModel(rawData as List, SystemLogsColumns(this)) - else -> LogsModel(rawData as List, WrapperLogColumns(this)) + private fun createModel(rawData: List): LogsModel = when (columnList) { + is WrapperLogColumns -> LogsModel(rawData as List, columnList) + is SystemLogColumns -> LogsModel(rawData as List, columnList) } + override val icon: Icon? = null + init { add(header, "wrap, growx, spanx 2") add( @@ -137,82 +167,132 @@ class LogPanel( resizeWeight = 0.6 }, ).apply { + isOneTouchExpandable = true resizeWeight = 0.1 }, "push, grow", ) - table.selectionModel.addListSelectionListener { selectionEvent -> - if (!selectionEvent.valueIsAdjusting) { - table.selectionModel.updateDetails() + table.apply { + selectionModel.addListSelectionListener { selectionEvent -> + if (!selectionEvent.valueIsAdjusting) { + selectionModel.updateDetails() + } + } + addPropertyChangeListener("model") { + header.displayedRows = model.rowCount } - } - - table.addPropertyChangeListener("model") { - header.displayedRows = table.model.rowCount - } - sidebar.list.checkBoxListSelectionModel.addListSelectionListener { - if (!it.valueIsAdjusting) { - updateData() + val clearAllMarks = Action("Clear all marks") { + model.markRows { false } } - } + actionMap.put( + "${ColumnControlButton.COLUMN_CONTROL_MARKER}.clearAllMarks", + clearAllMarks, + ) + attachPopupMenu { mouseEvent -> + val rowAtPoint = rowAtPoint(mouseEvent.point) + if (rowAtPoint != -1) { + addRowSelectionInterval(rowAtPoint, rowAtPoint) + } + val colAtPoint = columnAtPoint(mouseEvent.point) + if (colAtPoint != -1) { + JPopupMenu().apply { + val column = model.columns[convertColumnIndexToModel(colAtPoint)] + val event = model[convertRowIndexToModel(rowAtPoint)] + for (filterPanel in sidebar.filterPanels) { + filterPanel.customizePopupMenu(this, column, event) + } - header.search.addActionListener { updateData() } + if (colAtPoint == model.markIndex) { + add(clearAllMarks) + } - header.version.addActionListener { - table.selectionModel.updateDetails() + if (column == SystemLogColumns.Message || column == WrapperLogColumns.Message) { + add( + Action("Mark all with same message") { + model.markRows { row -> + (row.message == event.message).takeIf { it } + } + }, + ) + } + + if (event.stacktrace.isNotEmpty()) { + add( + Action("Mark all with same stacktrace") { + model.markRows { row -> + (row.stacktrace == event.stacktrace).takeIf { it } + } + }, + ) + } + + if (column == SystemLogColumns.Thread && event is SystemLogEvent) { + add( + Action("Mark all ${event.thread} events") { + model.markRows { row -> + ((row as SystemLogEvent).thread == event.thread).takeIf { it } + } + }, + ) + } + }.takeIf { it.componentCount > 0 } + } else { + null + } + } } - SelectedTimeZone.addChangeListener { zoneId -> - dateFormatter = dateFormatter.withZone(zoneId) - table.model.fireTableDataChanged() + header.apply { + search.addActionListener { + updateData() + } + version.addActionListener { + table.selectionModel.updateDetails() + } + showOnlyMarked.addPropertyChangeListener { e -> + if (e.propertyName == AbstractAction.SELECTED_KEY) { + updateData() + } + } } - ShowFullLoggerNames.addChangeListener { newValue -> + ShowFullLoggerNames.addChangeListener { table.model.fireTableDataChanged() - sidebar.list.isShowFullLoggerName = newValue } HyperlinkStrategy.addChangeListener { // if the link strategy changes, we need to rebuild all the hyperlinks table.selectionModel.updateDetails() } + + LogViewer.SelectedTimeZone.addChangeListener { + table.model.fireTableDataChanged() + } } private fun ListSelectionModel.updateDetails() { - details.events = selectedIndices - .filter { isSelectedIndex(it) } - .map { table.convertRowIndexToModel(it) } - .map { row -> table.model[row] } - .map { event -> - when (event) { - is SystemLogsEvent -> DetailEvent( - title = "${dateFormatter.format(event.timestamp)} ${event.thread}", - message = event.message, - body = event.stacktrace.map { element -> - if (UseHyperlinks.currentValue) { - element.toBodyLine((header.version.selectedItem as MajorVersion).version + ".0") - } else { - BodyLine(element) - } - }, - details = event.mdc, - ) - - is WrapperLogEvent -> DetailEvent( - title = dateFormatter.format(event.timestamp), - message = event.message, - body = event.stacktrace.map { element -> - if (UseHyperlinks.currentValue) { - element.toBodyLine((header.version.selectedItem as MajorVersion).version + ".0") - } else { - BodyLine(element) - } - }, - ) - } - } + details.events = selectedIndices.filter { isSelectedIndex(it) }.map { table.convertRowIndexToModel(it) }.map { row -> table.model[row] }.map { event -> + DetailEvent( + title = when (event) { + is SystemLogEvent -> "${TimeStampFormatter.format(event.timestamp)} ${event.thread}" + else -> TimeStampFormatter.format(event.timestamp) + }, + message = event.message, + body = event.stacktrace.map { element -> + if (UseHyperlinks.currentValue) { + element.toBodyLine((header.version.selectedItem as MajorVersion).version + ".0") + } else { + BodyLine(element) + } + }, + details = when (event) { + is SystemLogEvent -> event.mdc.associate { (key, value) -> key to value } + is WrapperLogEvent -> emptyMap() + }, + ) + } } inner class GroupingScrollBar : JScrollBar() { @@ -279,14 +359,12 @@ class LogPanel( } } - override val icon: Icon? = null - - class Header(private val totalRows: Int) : JPanel(MigLayout("ins 0, fill, hidemode 3")) { + private class Header(private val totalRows: Int) : JPanel(MigLayout("ins 0, fill, hidemode 3")) { private val events = JLabel("$totalRows (of $totalRows) events") val search = JXSearchField("Search") - val version: JComboBox = JComboBox(MajorVersion.values()).apply { + val version: JComboBox = JComboBox(Vector(MajorVersion.entries)).apply { selectedItem = MajorVersion.EightOne configureCellRenderer { _, value, _, _, _ -> text = "${value?.version}.*" @@ -294,6 +372,12 @@ class LogPanel( } private val versionLabel = JLabel("Version") + val showOnlyMarked = Action( + name = "Show Only Marked", + selected = false, + action = {}, + ) + private fun updateVersionVisibility() { val isVisible = UseHyperlinks.currentValue && HyperlinkStrategy.currentValue == LinkHandlingStrategy.OpenInBrowser version.isVisible = isVisible @@ -302,6 +386,7 @@ class LogPanel( init { add(events, "pushx") + add(JCheckBox(showOnlyMarked)) add(versionLabel) add(version) @@ -317,6 +402,91 @@ class LogPanel( } } + private inner class Sidebar(rawData: List) : FlatTabbedPane() { + private val names = NamePanel(rawData) + private val levels = LevelPanel(rawData) + + private val mdc: MDCPanel? = if (columnList == SystemLogColumns) { + @Suppress("UNCHECKED_CAST") + MDCPanel(rawData as List) + } else { + null + } + + private val threads: ThreadPanel? = if (columnList == SystemLogColumns) { + @Suppress("UNCHECKED_CAST") + ThreadPanel(rawData as List) + } else { + null + } + + private val time = TimePanel( + lowerBound = rawData.first().timestamp, + upperBound = rawData.last().timestamp, + ) + + val filterPanels: List = listOfNotNull( + names, + levels, + time, + mdc, + threads, + ) + + init { + tabLayoutPolicy = SCROLL_TAB_LAYOUT + tabsPopupPolicy = TabsPopupPolicy.asNeeded + scrollButtonsPolicy = ScrollButtonsPolicy.never + tabWidthMode = TabWidthMode.equal + tabType = TabType.underlined + tabHeight = 16 + + for (i in filterPanels.indices) { + val filterPanel = filterPanels[i] + addTab(filterPanel.tabName, filterPanel.component) + + filterPanel.addFilterChangeListener { + filterPanel.updateTabState() + updateData() + + selectedIndex = i + } + } + + attachPopupMenu { event -> + val tabIndex = indexAtLocation(event.x, event.y) + if (tabIndex == -1) return@attachPopupMenu null + + JPopupMenu().apply { + add( + Action("Reset") { + filterPanels[tabIndex].reset() + }, + ) + } + } + } + + private fun LogFilterPanel.updateTabState() { + val index = indexOfComponent(component) + if (isFilterApplied()) { + setBackgroundAt(index, UIManager.getColor("TabbedPane.focusColor")) + setTitleAt(index, "$tabName *") + } else { + setBackgroundAt(index, UIManager.getColor("TabbedPane.background")) + setTitleAt(index, tabName) + } + } + + override fun updateUI() { + super.updateUI() + @Suppress("UNNECESSARY_SAFE_CALL") + filterPanels?.forEach { + it.updateTabState() + } + } + } + companion object { private val BACKGROUND = CoroutineScope(Dispatchers.Default) @@ -339,102 +509,31 @@ class LogPanel( Duration.ofHours(12), Duration.ofDays(1), ) - - private val DEFAULT_WRAPPER_LOG_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss") - .withZone(ZoneId.systemDefault()) - private val DEFAULT_WRAPPER_MESSAGE_FORMAT = - "^[^|]+\\|(?[^|]+)\\|(?[^|]+)\\|(?: (?[TDIWE]) \\[(?[^]]++)] \\[(?