diff --git a/build.gradle.kts b/build.gradle.kts index cd86bc879..24c146e4c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,10 +23,7 @@ dependencies { api(libs.jsvg) api(libs.bundles.coroutines) api(libs.bundles.flatlaf) - api(libs.bundles.ignition) { - // Exclude transitive IA dependencies - we only need core Ignition classes for cache deserialization - isTransitive = false - } + api(libs.bundles.ignition) // Gradle will not include these packages unless they are transitive api(libs.poi) api(libs.excelkt) { // bringing in POI manually, since this wrapper appears unmaintained diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70221bf84..2beebdc1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ kotlin = "2.2.10" coroutines = "1.10.2" flatlaf = "3.6.1" kotest = "5.9.1" -ignition = "8.1.20" +ignition = "8.3.0" jackson = "2.19.2" [plugins] @@ -46,8 +46,8 @@ rsyntaxtextarea = { group = "com.fifesoft", name = "rsyntaxtextarea", version = jfreechart = { group = "org.jfree", name = "jfreechart", version = "1.5.6" } # Ignition -ignition-common = { group = "com.inductiveautomation.ignition", name = "common", version.ref = "ignition" } -ignition-gateway = { group = "com.inductiveautomation.ignition", name = "gateway-api", version.ref = "ignition" } +ignition-common = { group = "com.inductiveautomation.ignitionsdk", name = "ignition-common", version.ref = "ignition" } +ignition-gateway = { group = "com.inductiveautomation.ignitionsdk", name = "gateway-api", version.ref = "ignition" } # Ignition core types use classes from these libs (e.g. StringUtils, ImmutableMap), so we're forced to include these apache-commons = { group = "org.apache.commons", name = "commons-lang3", version = "3.8.1" } # This leads to a warning at runtime about multiple logging providers on the classpath, but is unavoidable diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheViewer.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheViewer.kt new file mode 100644 index 000000000..f121aa698 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheViewer.kt @@ -0,0 +1,39 @@ +package io.github.inductiveautomation.kindling.cache + +import com.formdev.flatlaf.extras.FlatSVGIcon +import io.github.inductiveautomation.kindling.core.Tool +import io.github.inductiveautomation.kindling.core.ToolOpeningException +import io.github.inductiveautomation.kindling.core.ToolPanel +import io.github.inductiveautomation.kindling.utils.FileFilter +import java.nio.file.Path +import kotlin.io.path.extension +import io.github.inductiveautomation.kindling.cache.hsql.CacheView as HsqlCacheView +import io.github.inductiveautomation.kindling.cache.sqlite.CacheView as IdbCacheView + +data object CacheViewer : Tool { + override val serialKey = "sf-cache" + override val title = "Store & Forward Cache" + override val description = "S&F Cache (.zip, 8.1: .data, .script, 8.3: .idb)" + override val icon = FlatSVGIcon("icons/bx-data.svg") + internal val extensions = arrayOf("data", "script", "zip", "idb") + override val filter = FileFilter(description, *extensions) + + override fun open(path: Path): ToolPanel = when (path.extension) { + "data", + "script", + -> { + HsqlCacheView(path) + } + "idb" -> { + IdbCacheView.fromIdb(path) + } + "zip" -> { + try { + HsqlCacheView(path) + } catch (_: Exception) { + IdbCacheView.fromZip(path) + } + } + else -> throw ToolOpeningException("Invalid file extension; ${path.extension}") + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheColumns.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheColumns.kt similarity index 92% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheColumns.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheColumns.kt index a0128bdc3..e577e77a7 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheColumns.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheColumns.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache +package io.github.inductiveautomation.kindling.cache.hsql import io.github.inductiveautomation.kindling.utils.ColumnList import org.jdesktop.swingx.renderer.DefaultTableRenderer diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheEntry.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheEntry.kt similarity index 77% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheEntry.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheEntry.kt index c3218a258..3d4fb04ca 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheEntry.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheEntry.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache +package io.github.inductiveautomation.kindling.cache.hsql import java.sql.Timestamp diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheView.kt similarity index 93% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheView.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheView.kt index 6f5cc692d..86e4d5bbb 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/CacheView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/CacheView.kt @@ -1,23 +1,22 @@ -package io.github.inductiveautomation.kindling.cache +package io.github.inductiveautomation.kindling.cache.hsql import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.jidesoft.swing.JideButton +import io.github.inductiveautomation.kindling.cache.CacheViewer import io.github.inductiveautomation.kindling.core.Detail import io.github.inductiveautomation.kindling.core.DetailsPane -import io.github.inductiveautomation.kindling.core.Tool import io.github.inductiveautomation.kindling.core.ToolOpeningException import io.github.inductiveautomation.kindling.core.ToolPanel import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.EDT_SCOPE -import io.github.inductiveautomation.kindling.utils.FileFilter import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.HorizontalSplitPane import io.github.inductiveautomation.kindling.utils.ReifiedJXTable import io.github.inductiveautomation.kindling.utils.ReifiedListTableModel import io.github.inductiveautomation.kindling.utils.TRANSACTION_GROUP_DATA import io.github.inductiveautomation.kindling.utils.VerticalSplitPane -import io.github.inductiveautomation.kindling.utils.deserializeStoreAndForward +import io.github.inductiveautomation.kindling.utils.deserializeJavaSerialized import io.github.inductiveautomation.kindling.utils.executeQuery import io.github.inductiveautomation.kindling.utils.get import io.github.inductiveautomation.kindling.utils.getLogger @@ -245,7 +244,7 @@ class CacheView(path: Path) : ToolPanel() { * We need the ID to get the table data and the schemaName to get the table columns and table name */ val id = table.model[table.selectedRow, CacheColumns.Id] - val raw = queryForData(id).deserializeStoreAndForward() + val raw = queryForData(id).deserializeJavaSerialized() val originalData = raw as Array<*> val cols = (originalData[0] as Array<*>).size val rows = originalData.size @@ -293,9 +292,9 @@ class CacheView(path: Path) : ToolPanel() { deserializedCache.getOrPut(id) { val bytes = queryForData(id) try { - val deserialized = bytes.deserializeStoreAndForward() + val deserialized = bytes.deserializeJavaSerialized() deserialized.toDetail() - } catch (e: Exception) { + } catch (_: Exception) { // It's not serialized with a class in the public API, or some other problem; // give up, and try to just dump the serialized data in a friendlier format val serializationDumper = deser.SerializationDumper(bytes) @@ -335,14 +334,3 @@ class CacheView(path: Path) : ToolPanel() { val cacheFileExtensions = listOf("data", "script", "log", "backup", "properties") } } - -data object CacheViewer : Tool { - override val serialKey = "sf-cache" - override val title = "Store & Forward Cache" - override val description = "S&F Cache (.data, .script, .zip)" - override val icon = FlatSVGIcon("icons/bx-hdd.svg") - internal val extensions = arrayOf("data", "script", "zip") - override val filter = FileFilter(description, *extensions) - - override fun open(path: Path): ToolPanel = CacheView(path) -} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/SchemaFilterList.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/SchemaFilterList.kt similarity index 85% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/SchemaFilterList.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/SchemaFilterList.kt index bcff415cf..60a51010e 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/SchemaFilterList.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/SchemaFilterList.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache +package io.github.inductiveautomation.kindling.cache.hsql import com.jidesoft.swing.CheckBoxList import io.github.inductiveautomation.kindling.utils.listCellRenderer @@ -11,16 +11,12 @@ class SchemaModel(data: List) : AbstractListModel() { private val comparator: Comparator = compareBy(nullsFirst()) { it.id } private val values = data.sortedWith(comparator) - override fun getSize(): Int { - return values.size + 1 - } + override fun getSize(): Int = values.size + 1 - override fun getElementAt(index: Int): Any { - return if (index == 0) { - CheckBoxList.ALL_ENTRY - } else { - values[index - 1] - } + override fun getElementAt(index: Int): Any = if (index == 0) { + CheckBoxList.ALL_ENTRY + } else { + values[index - 1] } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/SchemaModel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/SchemaModel.kt similarity index 76% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/SchemaModel.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/SchemaModel.kt index 4e01a81ab..cd54d44fc 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/SchemaModel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/SchemaModel.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache +package io.github.inductiveautomation.kindling.cache.hsql data class SchemaRecord( val id: Int, diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/AlarmJournalData.kt similarity index 97% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/AlarmJournalData.kt index f4bc9db22..db892b5a2 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AlarmJournalData.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/AlarmJournalData.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache.model +package io.github.inductiveautomation.kindling.cache.hsql.model import com.inductiveautomation.ignition.common.alarming.EventData import com.inductiveautomation.ignition.common.alarming.evaluation.EventPropertyType diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AuditProfileData.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/AuditProfileData.kt similarity index 66% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AuditProfileData.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/AuditProfileData.kt index 5568a0334..bca611395 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/AuditProfileData.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/AuditProfileData.kt @@ -1,12 +1,12 @@ -package io.github.inductiveautomation.kindling.cache.model +package io.github.inductiveautomation.kindling.cache.hsql.model -import com.inductiveautomation.ignition.gateway.audit.AuditRecord import io.github.inductiveautomation.kindling.core.Detail import java.io.Serializable +import java.util.Date @Suppress("unused") class AuditProfileData( - private val auditRecord: AuditRecord, + private val auditRecord: DefaultAuditRecord, private val insertQuery: String, private val parentLog: String, ) : Serializable { @@ -37,3 +37,21 @@ class AuditProfileData( private val serialVersionUID = 3037488986978918285L } } + +@Suppress("unused") +class DefaultAuditRecord( + val originatingContext: Int, + val statusCode: Int, + val action: String, + val actionTarget: String?, + val actionValue: String, + val actor: String, + val actorHost: String, + val originatingSystem: String, + val timestamp: Date, +) : Serializable { + companion object { + @JvmStatic + private val serialVersionUID = 0x21d9a89f09913781 + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/Dataset.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/Dataset.kt similarity index 98% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/Dataset.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/Dataset.kt index b2fcd7851..144797994 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/Dataset.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/Dataset.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache.model +package io.github.inductiveautomation.kindling.cache.hsql.model import com.inductiveautomation.ignition.common.Dataset import com.inductiveautomation.ignition.common.model.values.QualityCode diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/RemoteEvent.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/RemoteEvent.kt similarity index 87% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/RemoteEvent.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/RemoteEvent.kt index 60a6b5a99..ca93802ad 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/RemoteEvent.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/RemoteEvent.kt @@ -1,4 +1,6 @@ -package io.github.inductiveautomation.kindling.cache.model +@file:Suppress("DEPRECATION") + +package io.github.inductiveautomation.kindling.cache.hsql.model import com.inductiveautomation.ignition.common.alarming.AlarmPriority import com.inductiveautomation.ignition.common.alarming.AlarmStateTransition @@ -9,9 +11,9 @@ import com.inductiveautomation.ignition.gateway.alarming.journal.EventFlags.IS_C import com.inductiveautomation.ignition.gateway.alarming.journal.EventFlags.SHELVED_EVENT_FLAG import com.inductiveautomation.ignition.gateway.alarming.journal.EventFlags.SYSTEM_ACK_FLAG import com.inductiveautomation.ignition.gateway.alarming.journal.EventFlags.SYSTEM_EVENT_FLAG -import com.inductiveautomation.ignition.gateway.history.DatasourceData -import com.inductiveautomation.ignition.gateway.history.HistoricalData -import com.inductiveautomation.ignition.gateway.history.HistoryFlavor +import com.inductiveautomation.ignition.gateway.storeforward.data.PersistentData +import com.inductiveautomation.ignition.gateway.storeforward.deprecated.HistoricalData +import com.inductiveautomation.ignition.gateway.storeforward.deprecated.HistoryFlavor import io.github.inductiveautomation.kindling.core.Detail import java.io.Serial @@ -25,10 +27,11 @@ class RemoteEvent( val eventFlags: Int = 0, val data: EventData? = null, ) : HistoricalData { - override fun getFlavor(): HistoryFlavor = DatasourceData.FLAVOR + override fun getFlavor(): HistoryFlavor = HistoryFlavor("__datasourcedata__") override fun getSignature(): String = "Remote Alarm Journal Event" override fun getDataCount(): Int = 1 override fun getLoggerName(): String? = null + override fun upgrade(p0: String?): PersistentData? = null fun toDetail(): Detail { val details = mapOf( diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/ScriptedSFData.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/ScriptedSFData.kt similarity index 91% rename from src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/ScriptedSFData.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/ScriptedSFData.kt index 8586c8ecc..62ca19d6c 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/model/ScriptedSFData.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/hsql/model/ScriptedSFData.kt @@ -1,4 +1,4 @@ -package io.github.inductiveautomation.kindling.cache.model +package io.github.inductiveautomation.kindling.cache.hsql.model import io.github.inductiveautomation.kindling.core.Detail import java.io.Serializable diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/CacheView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/CacheView.kt new file mode 100644 index 000000000..782bac916 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/CacheView.kt @@ -0,0 +1,183 @@ +package io.github.inductiveautomation.kindling.cache.sqlite + +import io.github.inductiveautomation.kindling.core.Detail +import io.github.inductiveautomation.kindling.core.DetailsPane +import io.github.inductiveautomation.kindling.core.ToolPanel +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.HorizontalSplitPane +import io.github.inductiveautomation.kindling.utils.ReifiedJXTable +import io.github.inductiveautomation.kindling.utils.ReifiedLabelProvider.Companion.setDefaultRenderer +import io.github.inductiveautomation.kindling.utils.ReifiedListTableModel +import io.github.inductiveautomation.kindling.utils.SQLiteConnection +import io.github.inductiveautomation.kindling.utils.VerticalSplitPane +import io.github.inductiveautomation.kindling.utils.deserializeProto +import io.github.inductiveautomation.kindling.utils.executeQuery +import io.github.inductiveautomation.kindling.utils.get +import io.github.inductiveautomation.kindling.utils.selectedRowIndices +import io.github.inductiveautomation.kindling.utils.toDetail +import io.github.inductiveautomation.kindling.utils.toFileSizeLabel +import io.github.inductiveautomation.kindling.utils.toHumanReadableBinary +import io.github.inductiveautomation.kindling.utils.toList +import io.github.inductiveautomation.kindling.utils.transferTo +import io.github.inductiveautomation.kindling.utils.unzip +import org.sqlite.SQLiteConfig +import java.nio.file.FileSystems +import java.nio.file.Path +import java.sql.Connection +import javax.swing.ListSelectionModel +import kotlin.io.path.createTempDirectory +import kotlin.io.path.createTempFile +import kotlin.io.path.extension +import kotlin.io.path.inputStream +import kotlin.io.path.outputStream +import kotlin.io.path.walk + +class CacheView private constructor(connections: List) : ToolPanel() { + private val cacheData = connections.flatMap { conn -> + conn.executeQuery(CACHE_DATA_QUERY).toList { rs -> + val id: Int = rs["id"] + + IdbCacheEntry( + id = id, + dataSize = rs["data_length"], + timestamp = rs["timestamp"], + attemptCount = rs["attempt_count"], + dataCount = rs["data_count"], + flavorId = rs["flavor_id"], + flavorName = rs["flavor"], + quarantineId = rs["quarantine_id"], + reason = rs["reason"], + quarantineFlavorId = rs["quarantine_flavor_id"], + getData = { + conn.prepareStatement(GET_DATA_QUERY).run { + setInt(1, id) + executeQuery().toList { dataResult -> + dataResult.getBytes("data") + }.single() + } + }, + ) + } + } + + private val cacheTable = ReifiedJXTable( + ReifiedListTableModel(cacheData, IdbCacheColumns), + ).apply { + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + + setDefaultRenderer( + getText = { + if (it != null) { + "${it.size.toLong().toFileSizeLabel()} BLOB" + } else { + "" + } + }, + getTooltip = { "Export to CSV to view full data (b64 encoded)" }, + ) + } + + private val details = DetailsPane() + + private val dataFlavorFilterList = FilterList().apply { + setModel( + FilterModel( + cacheData.groupingBy(IdbCacheEntry::flavorName).eachCount(), + sortKey = { it }, + ), + ) + } + + override val icon = null + + init { + name = "S&F Cache View" + + add( + HorizontalSplitPane( + left = VerticalSplitPane( + top = FlatScrollPane(cacheTable), + bottom = details, + ), + right = FlatScrollPane(dataFlavorFilterList), + ), + "push, grow, span", + ) + + cacheTable.selectionModel.addListSelectionListener { event -> + if (!event.valueIsAdjusting) { + details.events = cacheTable.selectedRowIndices().first().let { index -> + val modelIndex = cacheTable.convertRowIndexToModel(index) + val entry = cacheTable.model[modelIndex] + + try { + val results = entry.data.deserializeProto(entry.flavorName) + + results.map { it.toDetail() } + } catch (_: Exception) { + val unzipped = entry.data.unzip() + + listOf( + Detail( + title = "Serialization dump of ${entry.data.size} bytes:", + body = try { + val serializationDumper = deser.SerializationDumper(unzipped) + serializationDumper.parseStream().lines() + } catch (_: Exception) { + unzipped.inputStream().use { + it.toHumanReadableBinary().split("\n") + } + }, + ), + ) + } + } + } + } + } + + companion object { + private const val CACHE_DATA_QUERY = """ + SELECT + d.id, + OCTET_LENGTH(d.data) as 'data_length', + d.timestamp, + d.attempt_count, + d.data_count, + d.flavor_id, + f.flavor, + d.quarantine_id, + q.reason, + q.flavor_id as 'quarantine_flavor_id' + FROM persistent_data d + LEFT JOIN persistent_flavors f ON f.id = d.flavor_id + LEFT JOIN quarantine_info q ON q.id = d.quarantine_id + """ + + private const val GET_DATA_QUERY = "SELECT data from persistent_data WHERE id = ?" + + fun fromZip(path: Path): CacheView { + val connections = FileSystems.newFileSystem(path).use { fs -> + val tempDir = createTempDirectory("kindling") + + fs.rootDirectories.first().walk().mapNotNull { + if (it.extension == "idb") { + val tempFile = createTempFile(tempDir, "kindling", "cache") + it.inputStream() transferTo tempFile.outputStream() + SQLiteConnection(tempFile, journalMode = SQLiteConfig.JournalMode.WAL) + } else { + null + } + }.toList() + } + + return CacheView(connections) + } + + fun fromConnection(connection: Connection): CacheView = CacheView(listOf(connection)) + + fun fromIdb(idbPath: Path) = fromConnection(SQLiteConnection(idbPath, journalMode = SQLiteConfig.JournalMode.WAL)) + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/IdbCacheColumns.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/IdbCacheColumns.kt new file mode 100644 index 000000000..d44b39617 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/IdbCacheColumns.kt @@ -0,0 +1,39 @@ +package io.github.inductiveautomation.kindling.cache.sqlite + +import io.github.inductiveautomation.kindling.utils.ColumnList +import io.github.inductiveautomation.kindling.utils.toFileSizeLabel +import org.jdesktop.swingx.renderer.DefaultTableRenderer +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +@Suppress("unused") +object IdbCacheColumns : ColumnList() { + val ID by column { it.id } + val Data by column( + column = { + cellRenderer = DefaultTableRenderer { + (it as Long).toFileSizeLabel() + } + }, + value = IdbCacheEntry::dataSize, + ) + val Timestamp by column( + column = { + cellRenderer = DefaultTableRenderer { + val datetime = ZonedDateTime.ofInstant(it as Instant, ZoneId.systemDefault()) + datetime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")) + } + }, + value = { + Instant.ofEpochMilli(it.timestamp) + }, + ) + val AttemptCount by column("Attempt Count") { it.attemptCount } + val DataCount by column("Data Count") { it.dataCount } + val FlavorId by column("Flavor ID") { it.flavorId } + val FlavorName by column("Flavor Name") { it.flavorName } + val QuarantineId by column("Quarantine ID") { it.quarantineId } + val Reason by column { it.reason } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/IdbCacheEntry.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/IdbCacheEntry.kt new file mode 100644 index 000000000..87971ffaa --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/cache/sqlite/IdbCacheEntry.kt @@ -0,0 +1,19 @@ +package io.github.inductiveautomation.kindling.cache.sqlite + +data class IdbCacheEntry( + val id: Int, + val dataSize: Long, + val timestamp: Long, + val attemptCount: Int, + val dataCount: Int, + val flavorId: Int, + val flavorName: String, + val quarantineId: Int?, + val reason: String?, + val quarantineFlavorId: Int?, + val getData: () -> ByteArray, +) { + val data by lazy { + getData() + } +} 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 7a207c57f..a063c3e8e 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt @@ -3,6 +3,7 @@ package io.github.inductiveautomation.kindling.idb import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane.TabType +import io.github.inductiveautomation.kindling.cache.sqlite.CacheView import io.github.inductiveautomation.kindling.core.MultiTool import io.github.inductiveautomation.kindling.core.ToolPanel import io.github.inductiveautomation.kindling.idb.generic.GenericView @@ -17,6 +18,8 @@ import io.github.inductiveautomation.kindling.utils.TabStrip import io.github.inductiveautomation.kindling.utils.executeQuery import io.github.inductiveautomation.kindling.utils.get import io.github.inductiveautomation.kindling.utils.toList +import org.sqlite.SQLiteConfig +import org.sqlite.SQLiteException import java.nio.file.Path import javax.swing.SwingConstants import kotlin.io.path.name @@ -64,7 +67,11 @@ class IdbView(paths: List) : ToolPanel() { class IdbConnection( val path: Path, ) : AutoCloseable { - val connection = SQLiteConnection(path) + val connection = try { + SQLiteConnection(path) + } catch (_: SQLiteException) { // Most likely journal mode is required. + SQLiteConnection(path, journalMode = SQLiteConfig.JournalMode.WAL) + } val tables = connection.metaData .getTables("", "", "", null) @@ -115,7 +122,11 @@ private enum class IdbTool { return SystemLogPanel(paths, logFiles) } }, - ; + Cache { + override fun supports(connections: List): Boolean = connections.all { "persistent_data" in it.tables } + + override fun open(connections: List): ToolPanel = CacheView.fromConnection(connections.single().connection) + }, ; open val tabName: String = name diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/ImagesTab.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/ImagesTab.kt index 7c2f70757..dd1c5376c 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/ImagesTab.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/ImagesTab.kt @@ -3,7 +3,7 @@ package io.github.inductiveautomation.kindling.idb import com.formdev.flatlaf.extras.FlatSVGIcon import com.github.weisj.jsvg.parser.LoaderContext import com.github.weisj.jsvg.parser.SVGLoader -import com.inductiveautomation.ignition.gateway.images.ImageFormat +import com.inductiveautomation.ignition.common.images.ImageFormat import com.jidesoft.comparator.AlphanumComparator import io.github.inductiveautomation.kindling.core.ToolPanel import io.github.inductiveautomation.kindling.core.ToolPanel.Companion.exportFileChooser diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt index 78d40568f..2e60ae86d 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt @@ -7,9 +7,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.OutputStream import java.util.Properties +import java.util.zip.GZIPInputStream import kotlin.math.log2 import kotlin.math.pow import kotlin.reflect.KProperty @@ -174,3 +176,24 @@ fun String.containsInOrder(pattern: String, ignoreCase: Boolean): Boolean { } return patternIndex == pattern.length } + +/** + * Unzips bytes if they are zipped with GZIP, otherwise returns the original ByteArray. + */ +fun ByteArray.unzip(): ByteArray { + if (size <= 2) return this + + val firstByte = this[0].toInt() + val secondByte = this[1].toInt() + + val header = (firstByte and 0xFF) or ((secondByte and 0xFF) shl 8) + + if (header != GZIPInputStream.GZIP_MAGIC) { + return this + } + + val byteOutput = ByteArrayOutputStream() + GZIPInputStream(inputStream()) transferTo byteOutput + + return byteOutput.toByteArray() +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Sql.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Sql.kt index 9eb079aea..026e1ec15 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Sql.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Sql.kt @@ -1,6 +1,7 @@ package io.github.inductiveautomation.kindling.utils import org.intellij.lang.annotations.Language +import org.sqlite.SQLiteConfig.JournalMode import org.sqlite.SQLiteConnection import org.sqlite.SQLiteDataSource import java.math.BigDecimal @@ -59,19 +60,20 @@ inline operator fun ResultSet.get(column: Int): T = sqliteCoercion(g inline operator fun ResultSet.get(column: String): T = sqliteCoercion(getObject(column)) -inline fun sqliteCoercion(raw: Any?): T = when { - raw is Int && T::class == Long::class -> raw.toLong() - raw is Int && T::class == Boolean::class -> raw == 1 +inline fun sqliteCoercion(raw: Any?): T = when (raw) { + is Int if T::class == Long::class -> raw.toLong() + is Int if T::class == Boolean::class -> raw == 1 else -> raw } as T fun SQLiteConnection( path: Path, readOnly: Boolean = true, + journalMode: JournalMode = JournalMode.OFF, ): SQLiteConnection = SQLiteDataSource().apply { url = "jdbc:sqlite:file:$path" setReadOnly(readOnly) - setJournalMode("OFF") + setJournalMode(journalMode.value) setSynchronous("OFF") }.connection as SQLiteConnection diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StoreAndForward.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StoreAndForward.kt index 9caaaf8a6..a78cce759 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StoreAndForward.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StoreAndForward.kt @@ -1,16 +1,22 @@ package io.github.inductiveautomation.kindling.utils -import com.inductiveautomation.ignition.gateway.history.BasicHistoricalRecord -import com.inductiveautomation.ignition.gateway.history.ScanclassHistorySet +import com.google.protobuf.GeneratedMessageV3 +import com.inductiveautomation.ignition.gateway.alarming.journal.encoding.AlarmJournalProto +import com.inductiveautomation.ignition.gateway.history.encoding.GenericObjectProto +import com.inductiveautomation.ignition.gateway.history.encoding.HistoryDataProto +import com.inductiveautomation.ignition.gateway.storeforward.deprecated.BasicHistoricalRecord +import com.inductiveautomation.ignition.gateway.storeforward.deprecated.ScanclassHistorySet import io.github.inductiveautomation.kindling.cache.AliasingObjectInputStream -import io.github.inductiveautomation.kindling.cache.model.AbstractDataset -import io.github.inductiveautomation.kindling.cache.model.AlarmJournalData -import io.github.inductiveautomation.kindling.cache.model.AlarmJournalSFGroup -import io.github.inductiveautomation.kindling.cache.model.AuditProfileData -import io.github.inductiveautomation.kindling.cache.model.BasicDataset -import io.github.inductiveautomation.kindling.cache.model.RemoteEvent -import io.github.inductiveautomation.kindling.cache.model.ScriptedSFData +import io.github.inductiveautomation.kindling.cache.hsql.model.AbstractDataset +import io.github.inductiveautomation.kindling.cache.hsql.model.AlarmJournalData +import io.github.inductiveautomation.kindling.cache.hsql.model.AlarmJournalSFGroup +import io.github.inductiveautomation.kindling.cache.hsql.model.AuditProfileData +import io.github.inductiveautomation.kindling.cache.hsql.model.BasicDataset +import io.github.inductiveautomation.kindling.cache.hsql.model.DefaultAuditRecord +import io.github.inductiveautomation.kindling.cache.hsql.model.RemoteEvent +import io.github.inductiveautomation.kindling.cache.hsql.model.ScriptedSFData import io.github.inductiveautomation.kindling.core.Detail +import java.io.DataInputStream import java.io.Serializable const val TRANSACTION_GROUP_DATA = "Transaction Group Data" @@ -50,14 +56,26 @@ fun Serializable.toDetail(): Detail = when (this) { /** * @throws ClassNotFoundException */ -fun ByteArray.deserializeStoreAndForward(): Serializable = AliasingObjectInputStream(inputStream()) { +fun ByteArray.deserializeJavaSerialized(): Serializable = AliasingObjectInputStream(inputStream()) { put("com.inductiveautomation.ignition.gateway.audit.AuditProfileData", AuditProfileData::class.java) - put("com.inductiveautomation.ignition.gateway.script.ialabs.IALabsDatasourceFunctions\$QuerySFData", ScriptedSFData::class.java) + put($$"com.inductiveautomation.ignition.gateway.script.ialabs.IALabsDatasourceFunctions$QuerySFData", ScriptedSFData::class.java) put("com.inductiveautomation.ignition.common.AbstractDataset", AbstractDataset::class.java) put("com.inductiveautomation.ignition.common.BasicDataset", BasicDataset::class.java) put("com.inductiveautomation.ignition.gateway.alarming.journal.remote.RemoteEvent", RemoteEvent::class.java) - put("com.inductiveautomation.ignition.gateway.alarming.journal.DatabaseAlarmJournal\$AlarmJournalSFData", AlarmJournalData::class.java) - put("com.inductiveautomation.ignition.gateway.alarming.journal.DatabaseAlarmJournal\$AlarmJournalSFGroup", AlarmJournalSFGroup::class.java) + put($$"com.inductiveautomation.ignition.gateway.alarming.journal.DatabaseAlarmJournal$AlarmJournalSFData", AlarmJournalData::class.java) + put($$"com.inductiveautomation.ignition.gateway.alarming.journal.DatabaseAlarmJournal$AlarmJournalSFGroup", AlarmJournalSFGroup::class.java) + put( + "com.inductiveautomation.ignition.gateway.history.PackedHistoricalQualifiedValue", + com.inductiveautomation.ignition.gateway.storeforward.deprecated.PackedHistoricalQualifiedValue::class.java, + ) + put( + "com.inductiveautomation.ignition.gateway.sqltags.model.BasicScanclassHistorySet", + com.inductiveautomation.ignition.gateway.storeforward.deprecated.BasicScanclassHistorySet::class.java, + ) + put( + "com.inductiveautomation.ignition.gateway.audit.DefaultAuditRecord", + DefaultAuditRecord::class.java, + ) }.readObject() as Serializable fun ScanclassHistorySet.toDetail(): Detail = Detail( @@ -99,3 +117,71 @@ fun BasicHistoricalRecord.toDetail(): Detail = Detail( "quoteColumnNames" to quoteColumnNames().toString(), ), ) + +// 8.3 + +fun ByteArray.deserializeProto(flavor: String): List { + val parseFunction: (ByteArray) -> GeneratedMessageV3 = when (flavor) { + "history_set" -> { + HistoryDataProto.HistorySetPB::parseFrom + } + "alarm_journal_event" -> { + AlarmJournalProto.JournalEventPB::parseFrom + } + else -> { + GenericObjectProto.GenericObjectPB::parseFrom + } + } + + return buildList { + DataInputStream(inputStream()).use { dis -> + while (dis.available() > 0) { + val length = dis.readInt() + val objectBytes = ByteArray(length) + dis.readFully(objectBytes) + + val byteData = objectBytes.unzip() + + add(parseFunction(byteData)) + } + } + } +} + +fun GeneratedMessageV3.toDetail() = when (this) { + is HistoryDataProto.HistorySetPB -> toDetail() + is AlarmJournalProto.JournalEventPB -> toDetail() + is GenericObjectProto.GenericObjectPB -> toDetail() + else -> error("Unknown class: ${this::class.java.name}") +} + +fun HistoryDataProto.HistorySetPB.toDetail(): Detail = Detail( + title = "Tag History Set of $tagValuesCount tag values", + body = buildList { + add("System: $systemName") + add("Provider: $providerName") + add("Tag Group: $tagGroupName") + add("Tag Values:") + addAll( + tagValuesList.map { + "Path: ${it.tagPath}\n${it.value}" + }, + ) + }, +) + +fun AlarmJournalProto.JournalEventPB.toDetail(): Detail = Detail( + title = "Alarm Journal Event", + body = listOf( + "Source: $source", + "Display Path: $displayPath", + "UUID: $uuid", + "System Type: $systemType", + "Target Journal: $targetJournal", + ), +) + +fun GenericObjectProto.GenericObjectPB.toDetail(): Detail = Detail( + title = "Generic Protobuf Object", + body = listOf(this.toString()), +) diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/xml/quarantine/QuarantineViewer.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/xml/quarantine/QuarantineViewer.kt index 84cf6a7cf..bf557e190 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/xml/quarantine/QuarantineViewer.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/xml/quarantine/QuarantineViewer.kt @@ -6,7 +6,7 @@ import io.github.inductiveautomation.kindling.core.DetailsPane import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.HorizontalSplitPane import io.github.inductiveautomation.kindling.utils.XML_FACTORY -import io.github.inductiveautomation.kindling.utils.deserializeStoreAndForward +import io.github.inductiveautomation.kindling.utils.deserializeJavaSerialized import io.github.inductiveautomation.kindling.utils.parse import io.github.inductiveautomation.kindling.utils.toDetail import io.github.inductiveautomation.kindling.xml.XmlTool @@ -49,7 +49,7 @@ internal class QuarantineViewer(data: List) : JPanel(MigLayout("i val detail by lazy { try { - binaryData.deserializeStoreAndForward().toDetail() + binaryData.deserializeJavaSerialized().toDetail() } catch (e: Exception) { XmlTool.logger.error("Unable to deserialize quarantine data", e) val serializedData = SerializationDumper(binaryData).parseStream().lines()