diff --git a/.gitignore b/.gitignore index b7ebccf366..7137efbf39 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ tags /docs/build/doctrees /test-utils/build /client/build +/explorer/build # gradle's buildSrc build/ /buildSrc/build/ diff --git a/.idea/modules.xml b/.idea/modules.xml index 63596a0a6f..ceb764a238 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -18,6 +18,9 @@ + + + diff --git a/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt b/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt index bc7d228089..007fd885b1 100644 --- a/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt +++ b/client/src/integration-test/kotlin/com/r3corda/client/WalletMonitorClientTests.kt @@ -105,9 +105,9 @@ class WalletMonitorClientTests { } ), expect { tx: ServiceToClientEvent.Transaction -> - require(tx.transaction.tx.inputs.isEmpty()) - require(tx.transaction.tx.outputs.size == 1) - val signaturePubKeys = tx.transaction.sigs.map { it.by }.toSet() + require(tx.transaction.inputs.isEmpty()) + require(tx.transaction.outputs.size == 1) + val signaturePubKeys = tx.transaction.mustSign.toSet() // Only Alice signed require(signaturePubKeys.size == 1) require(signaturePubKeys.contains(aliceNode.identity.owningKey)) @@ -137,9 +137,9 @@ class WalletMonitorClientTests { } ), expect { tx: ServiceToClientEvent.Transaction -> - require(tx.transaction.tx.inputs.size == 1) - require(tx.transaction.tx.outputs.size == 1) - val signaturePubKeys = tx.transaction.sigs.map { it.by }.toSet() + require(tx.transaction.inputs.size == 1) + require(tx.transaction.outputs.size == 1) + val signaturePubKeys = tx.transaction.mustSign.toSet() // Alice and Notary signed require(signaturePubKeys.size == 2) require(signaturePubKeys.contains(aliceNode.identity.owningKey)) diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt index 1c33e2a3bb..5191ea6cc8 100644 --- a/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ChosenList.kt @@ -21,7 +21,7 @@ import javafx.collections.ObservableListBase * The above will create a list that chooses and delegates to the appropriate filtered list based on the type of filter. */ class ChosenList( - private val chosenListObservable: ObservableValue> + private val chosenListObservable: ObservableValue> ): ObservableListBase() { private var currentList = chosenListObservable.value @@ -48,7 +48,7 @@ class ChosenList( } } - private fun pick(list: ObservableList) { + private fun pick(list: ObservableList) { currentList.removeListener(listener) list.addListener(listener) beginChange() diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/FlattenedList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/FlattenedList.kt new file mode 100644 index 0000000000..a417fa8e8f --- /dev/null +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/FlattenedList.kt @@ -0,0 +1,117 @@ +package com.r3corda.client.fxutils + +import javafx.beans.InvalidationListener +import javafx.beans.value.ChangeListener +import javafx.beans.value.ObservableValue +import javafx.collections.ListChangeListener +import javafx.collections.ObservableList +import javafx.collections.transformation.TransformationList +import org.eclipse.jetty.server.Authentication +import java.util.* + +/** + * [FlattenedList] flattens the passed in list of [ObservableValue]s so that changes in individual updates to the values + * are reflected in the exposed list as expected. + */ +class FlattenedList(val sourceList: ObservableList>) : TransformationList>(sourceList) { + + /** + * We maintain an ObservableValue->index map. This is needed because we need the ObservableValue's index in order to + * propagate a change and if the listener closure captures the index at the time of the call to + * [ObservableValue.addListener] it will become incorrect if the indices shift around later. + * + * Note that because of the bookkeeping required for this map, any remove operation and any add operation that + * inserts to the middle of the list will be O(N) as we need to scan the map and shift indices accordingly. + * + * Note also that we're wrapping each ObservableValue, this is required because we want to support reusing of + * ObservableValues and we need each to have a different hash. + */ + class WrappedObservableValue( + val observableValue: ObservableValue + ) + val indexMap = HashMap, Pair>>() + init { + sourceList.forEachIndexed { index, observableValue -> + val wrappedObservableValue = WrappedObservableValue(observableValue) + indexMap[wrappedObservableValue] = Pair(index, createListener(wrappedObservableValue)) + } + } + + private fun createListener(wrapped: WrappedObservableValue): ChangeListener { + val listener = ChangeListener { _observableValue, oldValue, newValue -> + val currentIndex = indexMap[wrapped]!!.first + beginChange() + nextReplace(currentIndex, currentIndex + 1, listOf(oldValue)) + endChange() + } + wrapped.observableValue.addListener(listener) + return listener + } + + override fun sourceChanged(c: ListChangeListener.Change>) { + beginChange() + while (c.next()) { + if (c.wasPermutated()) { + val from = c.from + val to = c.to + val permutation = IntArray(to, { c.getPermutation(it) }) + indexMap.replaceAll { _observableValue, pair -> Pair(permutation[pair.first], pair.second) } + nextPermutation(from, to, permutation) + } else if (c.wasUpdated()) { + throw UnsupportedOperationException("FlattenedList doesn't support Update changes") + } else { + val removed = c.removed + if (removed.size != 0) { + // TODO this assumes that if wasAdded() == true then we are adding elements to the getFrom() position + val removeStart = c.from + val removeRange = c.removed.size + val removeEnd = c.from + removeRange + val iterator = indexMap.iterator() + for (entry in iterator) { + val (wrapped, pair) = entry + val (index, listener) = pair + if (index >= removeStart) { + if (index < removeEnd) { + wrapped.observableValue.removeListener(listener) + iterator.remove() + } else { + // Shift indices + entry.setValue(Pair(index - removeRange, listener)) + } + } + } + nextRemove(removeStart, removed.map { it.value }) + } + if (c.wasAdded()) { + val addStart = c.from + val addEnd = c.to + val addRange = addEnd - addStart + // If it was a push to the end we don't need to shift indices + if (addStart != indexMap.size) { + val iterator = indexMap.iterator() + for (entry in iterator) { + val (index, listener) = entry.value + if (index >= addStart) { + // Shift indices + entry.setValue(Pair(index + addRange, listener)) + } + } + } + c.addedSubList.forEachIndexed { sublistIndex, observableValue -> + val wrapped = WrappedObservableValue(observableValue) + indexMap[wrapped] = Pair(addStart + sublistIndex, createListener(wrapped)) + } + nextAdd(addStart, addEnd) + } + } + } + endChange() + require(sourceList.size == indexMap.size) + } + + override fun get(index: Int) = sourceList.get(index).value + + override fun getSourceIndex(index: Int) = index + + override val size: Int get() = sourceList.size +} diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt new file mode 100644 index 0000000000..d88c886616 --- /dev/null +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt @@ -0,0 +1,104 @@ +package com.r3corda.client.fxutils + +import javafx.beans.binding.Bindings +import javafx.beans.property.ReadOnlyObjectWrapper +import javafx.beans.value.ObservableValue +import javafx.collections.ObservableList +import javafx.collections.transformation.FilteredList +import org.fxmisc.easybind.EasyBind +import java.util.function.Predicate + +/** + * Here follows utility extension functions that help reduce the visual load when developing RX code. Each function should + * have a short accompanying example code. + */ + +/** + * val person: ObservableValue = (..) + * val personName: ObservableValue = person.map { it.name } + */ +fun ObservableValue.map(function: (A) -> B): ObservableValue = EasyBind.map(this, function) + +/** + * val dogs: ObservableList = (..) + * val dogOwners: ObservableList = dogs.map { it.owner } + */ +fun ObservableList.map(function: (A) -> B): ObservableList = EasyBind.map(this, function) + +/** + * val aliceHeight: ObservableValue = (..) + * val bobHeight: ObservableValue = (..) + * fun sumHeight(a: Long, b: Long): Long { .. } + * + * val aliceBobSumHeight = ::sumHeight.lift(aliceHeight, bobHeight) + * val aliceHeightPlus2 = ::sumHeight.lift(aliceHeight, 2L.lift()) + */ +fun A.lift(): ObservableValue = ReadOnlyObjectWrapper(this) +fun ((A) -> R).lift( + arg0: ObservableValue +): ObservableValue = EasyBind.map(arg0, this) +fun ((A, B) -> R).lift( + arg0: ObservableValue, + arg1: ObservableValue +): ObservableValue = EasyBind.combine(arg0, arg1, this) +fun ((A, B, C) -> R).lift( + arg0: ObservableValue, + arg1: ObservableValue, + arg2: ObservableValue +): ObservableValue = EasyBind.combine(arg0, arg1, arg2, this) +fun ((A, B, C, D) -> R).lift( + arg0: ObservableValue, + arg1: ObservableValue, + arg2: ObservableValue, + arg3: ObservableValue +): ObservableValue = EasyBind.combine(arg0, arg1, arg2, arg3, this) + +/** + * data class Person(val height: ObservableValue) + * val person: ObservableValue = (..) + * val personHeight: ObservableValue = person.bind { it.height } + */ +fun ObservableValue.bind(function: (A) -> ObservableValue): ObservableValue = + // We cast here to enforce variance, flatMap should be covariant + @Suppress("UNCHECKED_CAST") + EasyBind.monadic(this).flatMap(function as (A) -> ObservableValue) + +/** + * enum class FilterCriterion { HEIGHT, NAME } + * val filterCriterion: ObservableValue = (..) + * val people: ObservableList = (..) + * fun filterFunction(filterCriterion: FilterCriterion): (Person) -> Boolean { .. } + * + * val filteredPeople: ObservableList = people.filter(filterCriterion.map(filterFunction)) + */ +fun ObservableList.filter(predicate: ObservableValue Boolean>): ObservableList { + // We cast here to enforce variance, FilteredList should be covariant + @Suppress("UNCHECKED_CAST") + return FilteredList(this as ObservableList).apply { + predicateProperty().bind(predicate.map { predicateFunction -> + Predicate { predicateFunction(it) } + }) + } +} + +/** + * val people: ObservableList = (..) + * val concatenatedNames = people.fold("", { names, person -> names + person.name }) + * val concatenatedNames2 = people.map(Person::name).fold("", String::plus) + */ +fun ObservableList.fold(initial: B, folderFunction: (B, A) -> B): ObservableValue { + return Bindings.createObjectBinding({ + var current = initial + forEach { + current = folderFunction(current, it) + } + current + }, arrayOf(this)) +} + +/** + * data class Person(val height: ObservableValue) + * val people: ObservableList = (..) + * val heights: ObservableList = people.map(Person::height).flatten() + */ +fun ObservableList>.flatten(): ObservableList = FlattenedList(this) diff --git a/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt b/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt index 9692ef04f6..1f234d783d 100644 --- a/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt +++ b/client/src/main/kotlin/com/r3corda/client/mock/EventGenerator.kt @@ -88,12 +88,21 @@ class EventGenerator( ) } + val exitCashGenerator = + amountIssuedGenerator.map { + ClientToServiceCommand.ExitCash( + it.withoutIssuer(), + it.token.issuer.reference + ) + } + val serviceToClientEventGenerator = Generator.frequency( 1.0 to outputStateGenerator ) val clientToServiceCommandGenerator = Generator.frequency( - 0.33 to issueCashGenerator, - 0.33 to moveCashGenerator + 0.4 to issueCashGenerator, + 0.5 to moveCashGenerator, + 0.1 to exitCashGenerator ) } diff --git a/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt b/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt index a16225c6d1..5ee19705c7 100644 --- a/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt +++ b/client/src/main/kotlin/com/r3corda/client/model/ContractStateModel.kt @@ -1,20 +1,23 @@ package com.r3corda.client.model +import com.r3corda.client.fxutils.foldToObservableList import com.r3corda.contracts.asset.Cash import com.r3corda.core.contracts.ContractState import com.r3corda.core.contracts.StateAndRef import com.r3corda.core.contracts.StateRef -import com.r3corda.client.fxutils.foldToObservableList import com.r3corda.node.services.monitor.ServiceToClientEvent import com.r3corda.node.services.monitor.StateSnapshotMessage import javafx.collections.ObservableList import kotlinx.support.jdk8.collections.removeIf import rx.Observable -class StatesDiff( - val added: Collection>, - val removed: Collection -) +sealed class StatesModification{ + class Diff( + val added: Collection>, + val removed: Collection + ) : StatesModification() + class Reset(val states: Collection>) : StatesModification() +} /** * This model exposes the list of owned contract states. @@ -24,16 +27,45 @@ class ContractStateModel { private val snapshot: Observable by observable(WalletMonitorModel::snapshot) private val outputStates = serviceToClient.ofType(ServiceToClientEvent.OutputState::class.java) - val contractStatesDiff = outputStates.map { StatesDiff(it.produced, it.consumed) } + val contractStatesDiff: Observable> = + outputStates.map { StatesModification.Diff(it.produced, it.consumed) } // We filter the diff first rather than the complete contract state list. - // TODO wire up snapshot once it holds StateAndRefs - val cashStatesDiff = contractStatesDiff.map { - StatesDiff(it.added.filterIsInstance>(), it.removed) - } + val cashStatesModification: Observable> = Observable.merge( + arrayOf( + contractStatesDiff.map { + StatesModification.Diff(it.added.filterCashStateAndRefs(), it.removed) + }, + snapshot.map { + StatesModification.Reset(it.contractStates.filterCashStateAndRefs()) + } + ) + ) val cashStates: ObservableList> = - cashStatesDiff.foldToObservableList(Unit) { statesDiff, _accumulator, observableList -> - observableList.removeIf { it.ref in statesDiff.removed } - observableList.addAll(statesDiff.added) + cashStatesModification.foldToObservableList(Unit) { statesDiff, _accumulator, observableList -> + when (statesDiff) { + is StatesModification.Diff -> { + observableList.removeIf { it.ref in statesDiff.removed } + observableList.addAll(statesDiff.added) + } + is StatesModification.Reset -> { + observableList.setAll(statesDiff.states) + } + } } + + companion object { + private fun Collection>.filterCashStateAndRefs(): List> { + return this.map { stateAndRef -> + @Suppress("UNCHECKED_CAST") + if (stateAndRef.state.data is Cash.State) { + // Kotlin doesn't unify here for some reason + stateAndRef as StateAndRef + } else { + null + } + }.filterNotNull() + } + } + } diff --git a/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt b/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt index 070700b6da..49e262f33b 100644 --- a/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt +++ b/client/src/main/kotlin/com/r3corda/client/model/GatheredTransactionDataModel.kt @@ -1,13 +1,16 @@ package com.r3corda.client.model import com.r3corda.client.fxutils.foldToObservableList -import com.r3corda.core.transactions.SignedTransaction +import com.r3corda.core.crypto.SecureHash +import com.r3corda.core.transactions.LedgerTransaction import com.r3corda.node.services.monitor.ServiceToClientEvent import com.r3corda.node.services.monitor.TransactionBuildResult import com.r3corda.node.utilities.AddOrRemove import javafx.beans.property.SimpleObjectProperty import javafx.beans.value.ObservableValue +import javafx.collections.FXCollections import javafx.collections.ObservableList +import org.jetbrains.exposed.sql.transactions.transaction import rx.Observable import java.time.Instant import java.util.UUID @@ -15,11 +18,12 @@ import java.util.UUID interface GatheredTransactionData { val fiberId: ObservableValue val uuid: ObservableValue - val protocolName: ObservableValue val protocolStatus: ObservableValue - val transaction: ObservableValue + val stateMachineStatus: ObservableValue + val transaction: ObservableValue val status: ObservableValue val lastUpdate: ObservableValue + val allEvents: ObservableList } sealed class TransactionCreateStatus(val message: String?) { @@ -28,21 +32,24 @@ sealed class TransactionCreateStatus(val message: String?) { override fun toString(): String = message ?: javaClass.simpleName } -sealed class ProtocolStatus(val status: String?) { - object Added: ProtocolStatus(null) - object Removed: ProtocolStatus(null) - class InProgress(status: String): ProtocolStatus(status) - override fun toString(): String = status ?: javaClass.simpleName +data class ProtocolStatus( + val status: String +) +sealed class StateMachineStatus(val stateMachineName: String) { + class Added(stateMachineName: String): StateMachineStatus(stateMachineName) + class Removed(stateMachineName: String): StateMachineStatus(stateMachineName) + override fun toString(): String = "${javaClass.simpleName}($stateMachineName)" } data class GatheredTransactionDataWritable( override val fiberId: SimpleObjectProperty = SimpleObjectProperty(null), override val uuid: SimpleObjectProperty = SimpleObjectProperty(null), - override val protocolName: SimpleObjectProperty = SimpleObjectProperty(null), + override val stateMachineStatus: SimpleObjectProperty = SimpleObjectProperty(null), override val protocolStatus: SimpleObjectProperty = SimpleObjectProperty(null), - override val transaction: SimpleObjectProperty = SimpleObjectProperty(null), + override val transaction: SimpleObjectProperty = SimpleObjectProperty(null), override val status: SimpleObjectProperty = SimpleObjectProperty(null), - override val lastUpdate: SimpleObjectProperty + override val lastUpdate: SimpleObjectProperty, + override val allEvents: ObservableList = FXCollections.observableArrayList() ) : GatheredTransactionData /** @@ -54,7 +61,7 @@ class GatheredTransactionDataModel { /** * Aggregation of updates to transactions. We use the observable list as the only container and do linear search for - * matching transactions because we have two keys(fiber ID and UUID) and this way it's easier to avoid syncing issues. + * matching transactions because we have three keys(fiber ID, UUID, tx id) and this way it's easier to avoid syncing issues. * * The Fiber ID is used to identify events that relate to the same transaction server-side, whereas the UUID is * generated on the UI and is used to identify events with the UI action that triggered them. Currently a UUID is @@ -64,46 +71,49 @@ class GatheredTransactionDataModel { * (Note that a transaction may be mapped by one or both) * TODO: Expose a writable stream to combine [serviceToClient] with to allow recording of transactions made locally(UUID) */ - val gatheredGatheredTransactionDataList: ObservableList = + val gatheredTransactionDataList: ObservableList = serviceToClient.foldToObservableList( initialAccumulator = Unit, folderFun = { serviceToClientEvent, _unit, transactionStates -> return@foldToObservableList when (serviceToClientEvent) { is ServiceToClientEvent.Transaction -> { - // TODO handle this once we have some id to associate the tx with + newTransactionIdTransactionStateOrModify(transactionStates, serviceToClientEvent, + transaction = serviceToClientEvent.transaction, + tweak = {} + ) } is ServiceToClientEvent.OutputState -> {} is ServiceToClientEvent.StateMachine -> { - newFiberIdTransactionStateOrModify(transactionStates, + newFiberIdTransactionStateOrModify(transactionStates, serviceToClientEvent, fiberId = serviceToClientEvent.fiberId, - lastUpdate = serviceToClientEvent.time, tweak = { - protocolName.set(serviceToClientEvent.label) - protocolStatus.set(when (serviceToClientEvent.addOrRemove) { - AddOrRemove.ADD -> ProtocolStatus.Added - AddOrRemove.REMOVE -> ProtocolStatus.Removed + stateMachineStatus.set(when (serviceToClientEvent.addOrRemove) { + AddOrRemove.ADD -> StateMachineStatus.Added(serviceToClientEvent.label) + AddOrRemove.REMOVE -> StateMachineStatus.Removed(serviceToClientEvent.label) }) } ) } is ServiceToClientEvent.Progress -> { - newFiberIdTransactionStateOrModify(transactionStates, + newFiberIdTransactionStateOrModify(transactionStates, serviceToClientEvent, fiberId = serviceToClientEvent.fiberId, - lastUpdate = serviceToClientEvent.time, tweak = { - protocolStatus.set(ProtocolStatus.InProgress(serviceToClientEvent.message)) + protocolStatus.set(ProtocolStatus(serviceToClientEvent.message)) } ) } is ServiceToClientEvent.TransactionBuild -> { val state = serviceToClientEvent.state - newUuidTransactionStateOrModify(transactionStates, + newUuidTransactionStateOrModify(transactionStates, serviceToClientEvent, uuid = serviceToClientEvent.id, fiberId = when (state) { is TransactionBuildResult.ProtocolStarted -> state.fiberId is TransactionBuildResult.Failed -> null }, - lastUpdate = serviceToClientEvent.time, + transactionId = when (state) { + is TransactionBuildResult.ProtocolStarted -> state.transaction?.id + is TransactionBuildResult.Failed -> null + }, tweak = { return@newUuidTransactionStateOrModify when (state) { is TransactionBuildResult.ProtocolStarted -> { @@ -122,50 +132,84 @@ class GatheredTransactionDataModel { ) companion object { - private fun newFiberIdTransactionStateOrModify( + + private fun newTransactionIdTransactionStateOrModify( transactionStates: ObservableList, - fiberId: Long, - lastUpdate: Instant, + event: ServiceToClientEvent, + transaction: LedgerTransaction, tweak: GatheredTransactionDataWritable.() -> Unit ) { - val index = transactionStates.indexOfFirst { it.fiberId.value == fiberId } - if (index < 0) { + val index = transactionStates.indexOfFirst { transaction.id == it.transaction.value?.id } + val state = if (index < 0) { val newState = GatheredTransactionDataWritable( - fiberId = SimpleObjectProperty(fiberId), - lastUpdate = SimpleObjectProperty(lastUpdate) + transaction = SimpleObjectProperty(transaction), + lastUpdate = SimpleObjectProperty(event.time) ) tweak(newState) transactionStates.add(newState) + newState } else { val existingState = transactionStates[index] - existingState.lastUpdate.set(lastUpdate) + existingState.lastUpdate.set(event.time) tweak(existingState) + existingState } + state.allEvents.add(event) + } + + private fun newFiberIdTransactionStateOrModify( + transactionStates: ObservableList, + event: ServiceToClientEvent, + fiberId: Long, + tweak: GatheredTransactionDataWritable.() -> Unit + ) { + val index = transactionStates.indexOfFirst { it.fiberId.value == fiberId } + val state = if (index < 0) { + val newState = GatheredTransactionDataWritable( + fiberId = SimpleObjectProperty(fiberId), + lastUpdate = SimpleObjectProperty(event.time) + ) + tweak(newState) + transactionStates.add(newState) + newState + } else { + val existingState = transactionStates[index] + existingState.lastUpdate.set(event.time) + tweak(existingState) + existingState + } + state.allEvents.add(event) } private fun newUuidTransactionStateOrModify( transactionStates: ObservableList, + event: ServiceToClientEvent, uuid: UUID, fiberId: Long?, - lastUpdate: Instant, + transactionId: SecureHash?, tweak: GatheredTransactionDataWritable.() -> Unit ) { val index = transactionStates.indexOfFirst { - it.uuid.value == uuid || (fiberId != null && it.fiberId.value == fiberId) + it.uuid.value == uuid || + (fiberId != null && it.fiberId.value == fiberId) || + (transactionId != null && it.transaction.value?.id == transactionId) } - if (index < 0) { + val state = if (index < 0) { val newState = GatheredTransactionDataWritable( uuid = SimpleObjectProperty(uuid), fiberId = SimpleObjectProperty(fiberId), - lastUpdate = SimpleObjectProperty(lastUpdate) + lastUpdate = SimpleObjectProperty(event.time) ) tweak(newState) transactionStates.add(newState) + newState } else { val existingState = transactionStates[index] - existingState.lastUpdate.set(lastUpdate) + existingState.lastUpdate.set(event.time) tweak(existingState) + existingState } + state.allEvents.add(event) } } diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt index 3a65ae920e..2f24118ad0 100644 --- a/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt @@ -8,28 +8,31 @@ import kotlin.test.fail class AggregatedListTest { var sourceList = FXCollections.observableArrayList() + var aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) } + var replayedList = ReplayedList(aggregatedList) @Before fun setup() { sourceList = FXCollections.observableArrayList() + aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) } + replayedList = ReplayedList(aggregatedList) } @Test fun addWorks() { - val aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) } - require(aggregatedList.size == 0) { "Aggregation is empty is source list is" } + require(replayedList.size == 0) { "Aggregation is empty is source list is" } sourceList.add(9) - require(aggregatedList.size == 1) { "Aggregation list has one element if one was added to source list" } - require(aggregatedList[0]!!.first == 0) + require(replayedList.size == 1) { "Aggregation list has one element if one was added to source list" } + require(replayedList[0]!!.first == 0) sourceList.add(8) - require(aggregatedList.size == 2) { "Aggregation list has two elements if two were added to source list with different keys" } + require(replayedList.size == 2) { "Aggregation list has two elements if two were added to source list with different keys" } sourceList.add(6) - require(aggregatedList.size == 2) { "Aggregation list's size doesn't change if element with existing key is added" } + require(replayedList.size == 2) { "Aggregation list's size doesn't change if element with existing key is added" } - aggregatedList.forEach { + replayedList.forEach { when (it.first) { 0 -> require(it.second.toSet() == setOf(6, 9)) 2 -> require(it.second.size == 1) @@ -40,11 +43,10 @@ class AggregatedListTest { @Test fun removeWorks() { - val aggregatedList = AggregatedList(sourceList, { it % 3 }) { mod3, group -> Pair(mod3, group) } sourceList.addAll(0, 1, 2, 3, 4) - require(aggregatedList.size == 3) - aggregatedList.forEach { + require(replayedList.size == 3) + replayedList.forEach { when (it.first) { 0 -> require(it.second.toSet() == setOf(0, 3)) 1 -> require(it.second.toSet() == setOf(1, 4)) @@ -54,8 +56,8 @@ class AggregatedListTest { } sourceList.remove(4) - require(aggregatedList.size == 3) - aggregatedList.forEach { + require(replayedList.size == 3) + replayedList.forEach { when (it.first) { 0 -> require(it.second.toSet() == setOf(0, 3)) 1 -> require(it.second.toSet() == setOf(1)) @@ -65,8 +67,8 @@ class AggregatedListTest { } sourceList.remove(2, 4) - require(aggregatedList.size == 2) - aggregatedList.forEach { + require(replayedList.size == 2) + replayedList.forEach { when (it.first) { 0 -> require(it.second.toSet() == setOf(0)) 1 -> require(it.second.toSet() == setOf(1)) @@ -75,7 +77,7 @@ class AggregatedListTest { } sourceList.removeAll(0, 1) - require(aggregatedList.size == 0) + require(replayedList.size == 0) } } diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/FlattenedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/FlattenedListTest.kt new file mode 100644 index 0000000000..646418b3ae --- /dev/null +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/FlattenedListTest.kt @@ -0,0 +1,113 @@ +package com.r3corda.client.fxutils + +import javafx.beans.property.SimpleObjectProperty +import javafx.collections.FXCollections +import org.junit.Before +import org.junit.Test + +class FlattenedListTest { + + var sourceList = FXCollections.observableArrayList(SimpleObjectProperty(1234)) + var flattenedList = FlattenedList(sourceList) + var replayedList = ReplayedList(flattenedList) + + @Before + fun setup() { + sourceList = FXCollections.observableArrayList(SimpleObjectProperty(1234)) + flattenedList = FlattenedList(sourceList) + replayedList = ReplayedList(flattenedList) + } + + @Test + fun addWorks() { + require(replayedList.size == 1) + require(replayedList[0] == 1234) + + sourceList.add(SimpleObjectProperty(12)) + require(replayedList.size == 2) + require(replayedList[0] == 1234) + require(replayedList[1] == 12) + + sourceList.add(SimpleObjectProperty(34)) + require(replayedList.size == 3) + require(replayedList[0] == 1234) + require(replayedList[1] == 12) + require(replayedList[2] == 34) + + sourceList.add(0, SimpleObjectProperty(56)) + require(replayedList.size == 4) + require(replayedList[0] == 56) + require(replayedList[1] == 1234) + require(replayedList[2] == 12) + require(replayedList[3] == 34) + + sourceList.addAll(2, listOf(SimpleObjectProperty(78), SimpleObjectProperty(910))) + require(replayedList.size == 6) + require(replayedList[0] == 56) + require(replayedList[1] == 1234) + require(replayedList[2] == 78) + require(replayedList[3] == 910) + require(replayedList[4] == 12) + require(replayedList[5] == 34) + } + + @Test + fun removeWorks() { + val firstRemoved = sourceList.removeAt(0) + require(firstRemoved.get() == 1234) + require(replayedList.size == 0) + firstRemoved.set(123) + + sourceList.add(SimpleObjectProperty(12)) + sourceList.add(SimpleObjectProperty(34)) + sourceList.add(SimpleObjectProperty(56)) + require(replayedList.size == 3) + val secondRemoved = sourceList.removeAt(1) + require(secondRemoved.get() == 34) + require(replayedList.size == 2) + require(replayedList[0] == 12) + require(replayedList[1] == 56) + secondRemoved.set(123) + + sourceList.clear() + require(replayedList.size == 0) + } + + @Test + fun updatingObservableWorks() { + require(replayedList[0] == 1234) + sourceList[0].set(4321) + require(replayedList[0] == 4321) + + sourceList.add(0, SimpleObjectProperty(12)) + sourceList[1].set(8765) + require(replayedList[0] == 12) + require(replayedList[1] == 8765) + + sourceList[0].set(34) + require(replayedList[0] == 34) + require(replayedList[1] == 8765) + } + + @Test + fun reusingObservableWorks() { + val observable = SimpleObjectProperty(12) + sourceList.add(observable) + sourceList.add(observable) + require(replayedList.size == 3) + require(replayedList[0] == 1234) + require(replayedList[1] == 12) + require(replayedList[2] == 12) + + observable.set(34) + require(replayedList.size == 3) + require(replayedList[0] == 1234) + require(replayedList[1] == 34) + require(replayedList[2] == 34) + + sourceList.removeAt(1) + require(replayedList.size == 2) + require(replayedList[0] == 1234) + require(replayedList[1] == 34) + } +} diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedList.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedList.kt new file mode 100644 index 0000000000..c9b828e9ab --- /dev/null +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedList.kt @@ -0,0 +1,63 @@ +package com.r3corda.client.fxutils + +import javafx.collections.ListChangeListener +import javafx.collections.ObservableList +import javafx.collections.transformation.TransformationList +import java.util.* + +/** + * This list type just replays changes propagated from the underlying source list. Used for testing changes. + */ +class ReplayedList(sourceList: ObservableList) : TransformationList(sourceList) { + + val replayedList = ArrayList(sourceList) + + override val size: Int get() = replayedList.size + + override fun sourceChanged(c: ListChangeListener.Change) { + + beginChange() + while (c.next()) { + if (c.wasPermutated()) { + val from = c.from + val to = c.to + val permutation = IntArray(to, { c.getPermutation(it) }) + val permutedSubList = ArrayList(to - from) + for (i in 0 .. (to - from - 1)) { + permutedSubList.add(replayedList[permutation[from + i]]) + } + permutedSubList.forEachIndexed { i, element -> + replayedList[from + i] = element + } + nextPermutation(from, to, permutation) + } else if (c.wasUpdated()) { + for (i in c.from .. c.to - 1) { + replayedList[i] = c.list[i] + nextUpdate(i) + } + } else { + if (c.wasRemoved()) { + // TODO this assumes that if wasAdded() == true then we are adding elements to the getFrom() position + val removePosition = c.from + for (i in 0 .. c.removedSize - 1) { + replayedList.removeAt(removePosition) + } + nextRemove(c.from, c.removed) + } + if (c.wasAdded()) { + val addStart = c.from + val addEnd = c.to + for (i in addStart .. addEnd - 1) { + replayedList.add(i, c.list[i]) + } + nextAdd(addStart, addEnd) + } + } + } + endChange() + } + + override fun getSourceIndex(index: Int) = index + + override fun get(index: Int) = replayedList[index] +} diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedListTest.kt new file mode 100644 index 0000000000..e0653eeb2e --- /dev/null +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedListTest.kt @@ -0,0 +1,86 @@ +package com.r3corda.client.fxutils + +import javafx.collections.FXCollections +import org.junit.Before +import org.junit.Test + +class ReplayedListTest { + + var sourceList = FXCollections.observableArrayList(1234) + var replayedList = ReplayedList(sourceList) + + @Before + fun setup() { + sourceList = FXCollections.observableArrayList(1234) + replayedList = ReplayedList(sourceList) + } + + @Test + fun addWorks() { + require(replayedList.size == 1) + require(replayedList[0] == 1234) + + sourceList.add(12) + require(replayedList.size == 2) + require(replayedList[0] == 1234) + require(replayedList[1] == 12) + + sourceList.add(34) + require(replayedList.size == 3) + require(replayedList[0] == 1234) + require(replayedList[1] == 12) + require(replayedList[2] == 34) + + sourceList.add(0, 56) + require(replayedList.size == 4) + require(replayedList[0] == 56) + require(replayedList[1] == 1234) + require(replayedList[2] == 12) + require(replayedList[3] == 34) + + sourceList.addAll(2, listOf(78, 910)) + require(replayedList.size == 6) + require(replayedList[0] == 56) + require(replayedList[1] == 1234) + require(replayedList[2] == 78) + require(replayedList[3] == 910) + require(replayedList[4] == 12) + require(replayedList[5] == 34) + } + + @Test + fun removeWorks() { + val firstRemoved = sourceList.removeAt(0) + require(firstRemoved == 1234) + require(replayedList.size == 0) + + sourceList.add(12) + sourceList.add(34) + sourceList.add(56) + require(replayedList.size == 3) + val secondRemoved = sourceList.removeAt(1) + require(secondRemoved == 34) + require(replayedList.size == 2) + require(replayedList[0] == 12) + require(replayedList[1] == 56) + + sourceList.clear() + require(replayedList.size == 0) + } + + @Test + fun updateWorks() { + require(replayedList[0] == 1234) + sourceList[0] = 4321 + require(replayedList[0] == 4321) + + sourceList.add(0, 12) + sourceList[1] = 8765 + require(replayedList[0] == 12) + require(replayedList[1] == 8765) + + sourceList[0] = 34 + require(replayedList[0] == 34) + require(replayedList[1] == 8765) + } +} diff --git a/explorer/build.gradle b/explorer/build.gradle new file mode 100644 index 0000000000..f74e581f3d --- /dev/null +++ b/explorer/build.gradle @@ -0,0 +1,77 @@ +group 'com.r3corda' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.0.3' + + repositories { + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +repositories { + mavenCentral() + maven { + url 'https://dl.bintray.com/kotlin/exposed' + } +} + +apply plugin: 'java' +apply plugin: 'kotlin' +apply plugin: 'application' + +sourceCompatibility = 1.8 + +applicationDefaultJvmArgs = ["-javaagent:${rootProject.configurations.quasar.singleFile}"] +mainClassName = 'com.r3corda.explorer.Main' + +sourceSets { + main { + resources { + srcDir "../config/dev" + } + } + test { + resources { + srcDir "../config/test" + } + } +} + +repositories { + jcenter() + mavenCentral() + mavenLocal() +} + +dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + testCompile group: 'junit', name: 'junit', version: '4.11' + + // TornadoFX: A lightweight Kotlin framework for working with JavaFX UI's. + compile 'no.tornado:tornadofx:1.5.1' + + // Corda Core: Data structures and basic types needed to work with Corda. + compile project(':core') + compile project(':client') + compile project(':node') + compile project(':contracts') + + // FontAwesomeFX: The "FontAwesome" icon library. + compile 'de.jensd:fontawesomefx-fontawesome:4.6.1-2' + + // ReactFX: Functional reactive UI programming. + compile 'org.reactfx:reactfx:2.0-M5' + compile 'org.fxmisc.easybind:easybind:1.0.3' + + // JFXtras: useful widgets including a calendar control. + compile 'org.jfxtras:jfxtras-agenda:8.0-r5' + compile 'org.jfxtras:jfxtras-font-roboto:8.0-r5' + + // Humanize: formatting + compile 'com.github.mfornos:humanize-icu:1.2.2' +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/AmountDiff.kt b/explorer/src/main/kotlin/com/r3corda/explorer/AmountDiff.kt new file mode 100644 index 0000000000..968f9c2480 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/AmountDiff.kt @@ -0,0 +1,26 @@ +package com.r3corda.explorer + +import com.r3corda.core.contracts.Amount + +enum class Positivity { + Positive, + Negative +} + +val Positivity.sign: String get() = when (this) { + Positivity.Positive -> "" + Positivity.Negative -> "-" +} + +data class AmountDiff( + val positivity: Positivity, + val amount: Amount +) { + companion object { + fun fromLong(quantity: Long, token: T) = + AmountDiff( + positivity = if (quantity < 0) Positivity.Negative else Positivity.Positive, + amount = Amount(Math.abs(quantity), token) + ) + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/Main.kt b/explorer/src/main/kotlin/com/r3corda/explorer/Main.kt new file mode 100644 index 0000000000..af6b79b929 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/Main.kt @@ -0,0 +1,82 @@ +package com.r3corda.explorer + +import com.r3corda.client.WalletMonitorClient +import com.r3corda.client.mock.EventGenerator +import com.r3corda.client.mock.Generator +import com.r3corda.client.mock.oneOf +import com.r3corda.client.model.Models +import com.r3corda.client.model.WalletMonitorModel +import com.r3corda.client.model.observer +import com.r3corda.core.contracts.ClientToServiceCommand +import com.r3corda.explorer.model.IdentityModel +import com.r3corda.node.driver.PortAllocation +import com.r3corda.node.driver.driver +import com.r3corda.node.driver.startClient +import com.r3corda.node.services.monitor.ServiceToClientEvent +import com.r3corda.node.services.transactions.SimpleNotaryService +import javafx.stage.Stage +import rx.Observer +import rx.subjects.PublishSubject +import tornadofx.App +import java.util.* + +class Main : App() { + override val primaryView = MainWindow::class + val aliceOutStream: Observer by observer(WalletMonitorModel::clientToService) + + override fun start(stage: Stage) { + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + throwable.printStackTrace() + System.exit(1) + } + + super.start(stage) + + // start the driver on another thread + // TODO Change this to connecting to an actual node (specified on cli/in a config) once we're happy with the code + Thread({ + + val portAllocation = PortAllocation.Incremental(20000) + driver(portAllocation = portAllocation) { + + val aliceNodeFuture = startNode("Alice") + val bobNodeFuture = startNode("Bob") + val notaryNodeFuture = startNode("Notary", advertisedServices = setOf(SimpleNotaryService.Type)) + + val aliceNode = aliceNodeFuture.get() + val bobNode = bobNodeFuture.get() + val notaryNode = notaryNodeFuture.get() + + val aliceClient = startClient(aliceNode).get() + + Models.get(Main::class).myIdentity.set(aliceNode.identity) + Models.get(Main::class).register(aliceClient, aliceNode) + + val bobInStream = PublishSubject.create() + val bobOutStream = PublishSubject.create() + + val bobClient = startClient(bobNode).get() + val bobMonitorClient = WalletMonitorClient(bobClient, bobNode, bobOutStream, bobInStream, PublishSubject.create()) + assert(bobMonitorClient.register().get()) + + for (i in 0 .. 10000) { + Thread.sleep(500) + + val eventGenerator = EventGenerator( + parties = listOf(aliceNode.identity, bobNode.identity), + notary = notaryNode.identity + ) + + eventGenerator.clientToServiceCommandGenerator.combine(Generator.oneOf(listOf(aliceOutStream, bobOutStream))) { + command, stream -> stream.onNext(command) + }.generate(Random()) + } + + waitForAllNodesToFinish() + } + + }).start() + } +} + diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/MainWindow.kt b/explorer/src/main/kotlin/com/r3corda/explorer/MainWindow.kt new file mode 100644 index 0000000000..f9537b1400 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/MainWindow.kt @@ -0,0 +1,26 @@ +package com.r3corda.explorer + +import com.r3corda.explorer.views.TopLevel +import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory +import jfxtras.resources.JFXtrasFontRoboto +import tornadofx.* + +/** + * The root view embeds the [Shell] and provides support for the status bar, and modal dialogs. + */ +class MainWindow : View() { + private val toplevel: TopLevel by inject() + override val root = toplevel.root + + init { + // Do this first before creating the notification bar, so it can autosize itself properly. + loadFontsAndStyles() + } + + private fun loadFontsAndStyles() { + JFXtrasFontRoboto.loadAll() + importStylesheet("/com/r3corda/explorer/css/wallet.css") + FontAwesomeIconFactory.get() // Force initialisation. + root.styleClass += "root" + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/formatters/AmountFormatter.kt b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/AmountFormatter.kt new file mode 100644 index 0000000000..0ec38d6c40 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/AmountFormatter.kt @@ -0,0 +1,17 @@ +package com.r3corda.explorer.formatters + +import com.r3corda.core.contracts.Amount +import java.util.Currency + +/** + * A note on formatting: Currently we don't have any fancy locale/use-case-specific formatting of amounts. This is a + * non-trivial problem that requires substantial work. + * Libraries to evaluate: IBM ICU currency library, github.com/mfornos/humanize, JSR 354 ref. implementation + */ + +object AmountFormatter { + // TODO replace this once we settled on how we do formatting + val boring = object : Formatter> { + override fun format(value: Amount) = "${value.quantity} ${value.token}" + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/formatters/Formatter.kt b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/Formatter.kt new file mode 100644 index 0000000000..71e11f193b --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/Formatter.kt @@ -0,0 +1,6 @@ +package com.r3corda.explorer.formatters + + +interface Formatter { + fun format(value: T): String +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/formatters/NumberFormatter.kt b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/NumberFormatter.kt new file mode 100644 index 0000000000..9fe6616a78 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/formatters/NumberFormatter.kt @@ -0,0 +1,10 @@ +package com.r3corda.explorer.formatters + +object NumberFormatter { + // TODO replace this once we settled on how we do formatting + val boring = object : Formatter { + override fun format(value: Any) = value.toString() + } + + val boringLong: Formatter = boring +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/IdentityModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/IdentityModel.kt new file mode 100644 index 0000000000..c2813d849b --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/IdentityModel.kt @@ -0,0 +1,9 @@ +package com.r3corda.explorer.model + +import com.r3corda.core.crypto.Party +import javafx.beans.property.SimpleObjectProperty + + +class IdentityModel { + val myIdentity = SimpleObjectProperty() +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/ReportingCurrencyModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/ReportingCurrencyModel.kt new file mode 100644 index 0000000000..9d49555abc --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/ReportingCurrencyModel.kt @@ -0,0 +1,23 @@ +package com.r3corda.explorer.model + +import com.r3corda.core.contracts.Amount +import com.r3corda.client.fxutils.AmountBindings +import com.r3corda.client.model.ExchangeRate +import com.r3corda.client.model.ExchangeRateModel +import com.r3corda.client.model.observableValue +import javafx.beans.value.ObservableValue +import org.fxmisc.easybind.EasyBind +import java.util.* + +class ReportingCurrencyModel { + private val exchangeRate: ObservableValue by observableValue(ExchangeRateModel::exchangeRate) + val reportingCurrency: ObservableValue by observableValue(SettingsModel::reportingCurrency) + /** + * This stream provides a stream of exchange() functions that updates when either the reporting currency or the + * exchange rates change + */ + val reportingExchange: ObservableValue) -> Amount>> = + EasyBind.map(AmountBindings.exchange(reportingCurrency, exchangeRate)) { Pair(it.first) { amount: Amount -> + Amount(it.second(amount), it.first) + }} +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/SettingsModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/SettingsModel.kt new file mode 100644 index 0000000000..994a91c263 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/SettingsModel.kt @@ -0,0 +1,11 @@ +package com.r3corda.explorer.model + +import com.r3corda.core.contracts.USD +import javafx.beans.property.SimpleObjectProperty +import java.util.* + +class SettingsModel { + + val reportingCurrency: SimpleObjectProperty = SimpleObjectProperty(USD) + +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/model/TopLevelModel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/model/TopLevelModel.kt new file mode 100644 index 0000000000..f7e424fb44 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/model/TopLevelModel.kt @@ -0,0 +1,13 @@ +package com.r3corda.explorer.model + +import javafx.beans.property.SimpleObjectProperty + +enum class SelectedView { + Home, + Cash, + Transaction +} + +class TopLevelModel { + val selectedView = SimpleObjectProperty(SelectedView.Home) +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/ListViewUtilities.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/ListViewUtilities.kt new file mode 100644 index 0000000000..3cde116ecc --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/ListViewUtilities.kt @@ -0,0 +1,38 @@ +package com.r3corda.explorer.ui + +import com.r3corda.explorer.formatters.Formatter +import javafx.scene.Node +import javafx.scene.control.ListCell +import javafx.scene.control.ListView +import javafx.util.Callback + +fun Formatter.toListCellFactory() = Callback, ListCell> { + object : ListCell() { + override fun updateItem(value: T?, empty: Boolean) { + super.updateItem(value, empty) + text = if (value == null || empty) { + "" + } else { + format(value) + } + } + } +} + +fun ListView.setCustomCellFactory(toNode: (T) -> Node) { + setCellFactory { + object : ListCell() { + init { + text = null + } + override fun updateItem(value: T?, empty: Boolean) { + super.updateItem(value, empty) + graphic = if (value != null && !empty) { + toNode(value) + } else { + null + } + } + } + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/SingleRowSelection.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/SingleRowSelection.kt new file mode 100644 index 0000000000..bb441fabad --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/SingleRowSelection.kt @@ -0,0 +1,6 @@ +package com.r3corda.explorer.ui + +sealed class SingleRowSelection { + class None : SingleRowSelection() + class Selected(val node: A) : SingleRowSelection() +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/TableViewUtilities.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TableViewUtilities.kt new file mode 100644 index 0000000000..b4887f5099 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TableViewUtilities.kt @@ -0,0 +1,76 @@ +package com.r3corda.explorer.ui + +import com.r3corda.explorer.formatters.Formatter +import javafx.beans.binding.Bindings +import javafx.beans.value.ObservableValue +import javafx.scene.Node +import javafx.scene.control.ListCell +import javafx.scene.control.TableCell +import javafx.scene.control.TableColumn +import javafx.scene.control.TableView +import javafx.util.Callback +import org.fxmisc.easybind.EasyBind + +fun TableView.setColumnPrefWidthPolicy( + getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TableColumn) -> Number +) { + val tableWidthWithoutPaddingAndBorder = Bindings.createDoubleBinding({ + val padding = padding + val borderInsets = border?.insets + width - + (if (padding != null) padding.left + padding.right else 0.0) - + (if (borderInsets != null) borderInsets.left + borderInsets.right else 0.0) + }, arrayOf(columns, widthProperty(), paddingProperty(), borderProperty())) + + columns.forEach { + it.setPrefWidthPolicy(tableWidthWithoutPaddingAndBorder, getColumnWidth) + } +} + +private fun TableColumn.setPrefWidthPolicy( + widthWithoutPaddingAndBorder: ObservableValue, + getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TableColumn) -> Number +) { + prefWidthProperty().bind(EasyBind.map(widthWithoutPaddingAndBorder) { + getColumnWidth(it, this) + }) +} + +fun Formatter.toTableCellFactory() = Callback, TableCell> { + object : TableCell() { + override fun updateItem(value: T?, empty: Boolean) { + super.updateItem(value, empty) + text = if (value == null || empty) { + "" + } else { + format(value) + } + } + } +} + +fun TableView.singleRowSelection() = Bindings.createObjectBinding({ + if (selectionModel.selectedItems.size == 0) { + SingleRowSelection.None() + } else { + SingleRowSelection.Selected(selectionModel.selectedItems[0]) + } +}, arrayOf(selectionModel.selectedItems)) + +fun TableColumn.setCustomCellFactory(toNode: (T) -> Node) { + setCellFactory { + object : TableCell() { + init { + text = null + } + override fun updateItem(value: T?, empty: Boolean) { + super.updateItem(value, empty) + graphic = if (value != null && !empty) { + toNode(value) + } else { + null + } + } + } + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/ui/TreeTableViewUtilities.kt b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TreeTableViewUtilities.kt new file mode 100644 index 0000000000..f2730f4196 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/ui/TreeTableViewUtilities.kt @@ -0,0 +1,58 @@ +package com.r3corda.explorer.ui + +import com.r3corda.explorer.formatters.Formatter +import javafx.beans.binding.Bindings +import javafx.beans.value.ObservableValue +import javafx.scene.control.TreeTableCell +import javafx.scene.control.TreeTableColumn +import javafx.scene.control.TreeTableView +import javafx.util.Callback +import org.fxmisc.easybind.EasyBind + + +fun TreeTableView.setColumnPrefWidthPolicy( + getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TreeTableColumn) -> Number +) { + val tableWidthWithoutPaddingAndBorder = Bindings.createDoubleBinding({ + val padding = padding + val borderInsets = border?.insets + width - + (if (padding != null) padding.left + padding.right else 0.0) - + (if (borderInsets != null) borderInsets.left + borderInsets.right else 0.0) + }, arrayOf(columns, widthProperty(), paddingProperty(), borderProperty())) + + columns.forEach { + it.setPrefWidthPolicy(tableWidthWithoutPaddingAndBorder, getColumnWidth) + } +} + +private fun TreeTableColumn.setPrefWidthPolicy( + widthWithoutPaddingAndBorder: ObservableValue, + getColumnWidth: (tableWidthWithoutPaddingAndBorder: Number, column: TreeTableColumn) -> Number +) { + prefWidthProperty().bind(EasyBind.map(widthWithoutPaddingAndBorder) { + getColumnWidth(it, this) + }) +} + +fun Formatter.toTreeTableCellFactory() = Callback, TreeTableCell> { + object : TreeTableCell() { + override fun updateItem(value: T?, empty: Boolean) { + super.updateItem(value, empty) + text = if (value == null || empty) { + "" + } else { + format(value) + } + } + } +} + +fun TreeTableView.singleRowSelection(): ObservableValue> = + Bindings.createObjectBinding({ + if (selectionModel.selectedItems.size == 0) { + SingleRowSelection.None() + } else { + SingleRowSelection.Selected(selectionModel.selectedItems[0].value) + } + }, arrayOf(selectionModel.selectedItems)) diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/CashViewer.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/CashViewer.kt new file mode 100644 index 0000000000..26fb675534 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/CashViewer.kt @@ -0,0 +1,364 @@ +package com.r3corda.explorer.views + +import com.r3corda.client.fxutils.* +import com.r3corda.client.model.ContractStateModel +import com.r3corda.client.model.observableList +import com.r3corda.client.model.observableValue +import com.r3corda.contracts.asset.Cash +import com.r3corda.core.contracts.Amount +import com.r3corda.core.contracts.StateAndRef +import com.r3corda.core.contracts.withoutIssuer +import com.r3corda.core.crypto.Party +import com.r3corda.explorer.formatters.AmountFormatter +import com.r3corda.explorer.model.ReportingCurrencyModel +import com.r3corda.explorer.model.SettingsModel +import com.r3corda.explorer.ui.* +import javafx.beans.binding.Bindings +import javafx.beans.value.ObservableValue +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.scene.Node +import javafx.scene.control.* +import javafx.scene.image.ImageView +import javafx.scene.input.MouseButton +import javafx.scene.input.MouseEvent +import javafx.scene.layout.HBox +import javafx.scene.layout.VBox +import org.fxmisc.easybind.EasyBind +import tornadofx.UIComponent +import tornadofx.View +import java.time.LocalDateTime +import java.util.* + +sealed class FilterCriteria { + abstract fun matches(string: String): Boolean + + object All : FilterCriteria() { + override fun matches(string: String) = true + } + + class FilterString(val filterString: String) : FilterCriteria() { + override fun matches(string: String) = string.contains(filterString) + } +} + +class CashViewer : View() { + // Inject UI elements + override val root: SplitPane by fxml() + + val topSplitPane: SplitPane by fxid() + // Left pane + val leftPane: VBox by fxid() + val searchCriteriaTextField: TextField by fxid() + val searchCancelImageView: ImageView by fxid() + val totalMatchingLabel: Label by fxid() + val cashViewerTable: TreeTableView by fxid() + val cashViewerTableIssuerCurrency: TreeTableColumn by fxid() + val cashViewerTableLocalCurrency: TreeTableColumn?> by fxid() + val cashViewerTableEquiv: TreeTableColumn?> by fxid() + + // Right pane + val rightPane: VBox by fxid() + val totalPositionsLabel: Label by fxid() + val equivSumLabel: Label by fxid() + val cashStatesList: ListView by fxid() + + // Inject observables + val cashStates by observableList(ContractStateModel::cashStates) + val reportingCurrency: ObservableValue by observableValue(SettingsModel::reportingCurrency) + val reportingExchange: ObservableValue) -> Amount>> + by observableValue(ReportingCurrencyModel::reportingExchange) + + /** + * This holds the data for each row in the TreeTable. + */ + sealed class ViewerNode { + object Root : ViewerNode() + class IssuerNode( + val issuer: Party, + val sumEquivAmount: ObservableValue>, + val states: ObservableList> + ) : ViewerNode() + class CurrencyNode( + val amount: ObservableValue>, + val equivAmount: ObservableValue>, + val states: ObservableList> + ) : ViewerNode() + } + + /** + * We allow filtering by both issuer and currency. We do this by filtering by both at the same time and picking the + * one which produces more results, which seems to work, as the set of currency strings don't really overlap with + * issuer strings. + */ + + /** + * Holds the filtering criterion based on the input text + */ + private val filterCriteria = searchCriteriaTextField.textProperty().map { text -> + if (text.isBlank()) { + FilterCriteria.All + } else { + FilterCriteria.FilterString(text) + } + } + + /** + * Filter cash states based on issuer. + */ + private val issueFilteredCashStates = cashStates.filter(filterCriteria.map { criteria -> + { state: StateAndRef -> + criteria.matches(state.state.data.amount.token.issuer.party.toString()) + } + }) + /** + * Now filter cash states based on currency. + */ + private val currencyFilteredCashStates = cashStates.filter(filterCriteria.map { criteria -> + { state: StateAndRef -> + criteria.matches(state.state.data.amount.token.product.toString()) + } + }) + + /** + * Now we pick which one to use. + */ + private val filteredCashStates = ChosenList(filterCriteria.map { + if (issueFilteredCashStates.size > currencyFilteredCashStates.size) { + issueFilteredCashStates + } else { + currencyFilteredCashStates + } + }) + + /** + * This is where we aggregate the list of cash states into the TreeTable structure. + */ + val cashViewerIssueNodes: ObservableList> = + /** + * First we group the states based on the issuer. [memberStates] is all states holding currency issued by [issuer] + */ + AggregatedList(filteredCashStates, { it.state.data.amount.token.issuer.party }) { issuer, memberStates -> + /** + * Next we create subgroups based on currency. [memberStates] here is all states holding currency [currency] issued by [issuer] above. + * Note that these states will not be displayed in the TreeTable, but rather in the side pane if the user clicks on the row. + */ + val currencyNodes = AggregatedList(memberStates, { it.state.data.amount.token.product }) { currency, memberStates -> + /** + * We sum the states in the subgroup, to be displayed in the "Local Currency" column + */ + val amounts = memberStates.map { it.state.data.amount.withoutIssuer() } + val sumAmount = amounts.fold(Amount(0, currency), Amount::plus) + + /** + * We exchange the sum to the reporting currency, to be displayed in the " Equiv" column. + */ + val equivSumAmount = EasyBind.combine(sumAmount, reportingExchange) { sum, exchange -> + exchange.second(sum) + } + /** + * Finally assemble the actual TreeTable Currency node. + */ + TreeItem(ViewerNode.CurrencyNode(sumAmount, equivSumAmount, memberStates)) + } + + /** + * Now that we have all nodes per currency, we sum the exchanged amounts, to be displayed in the + * " Equiv" column, this time on the issuer level. + */ + val equivAmounts = currencyNodes.map { it.value.equivAmount }.flatten() + val equivSumAmount = reportingCurrency.bind { currency -> + equivAmounts.fold(Amount(0, currency), Amount::plus) + } + + /** + * Assemble the Issuer node. + */ + val treeItem = TreeItem(ViewerNode.IssuerNode(issuer, equivSumAmount, memberStates)) + + /** + * Bind the children in the TreeTable structure. + * + * TODO Perhaps we shouldn't do this here, but rather have a generic way of binding nodes to the treetable once. + */ + treeItem.isExpanded = true + val children: List> = treeItem.children + Bindings.bindContent(children, currencyNodes) + treeItem + } + + /** + * Now we build up the Observables needed for the side pane, given that the user clicks on a row. + */ + val selectedViewerNode = cashViewerTable.singleRowSelection() + + /** + * Holds data for a single state, to be displayed in the list in the side pane. + */ + data class StateRow ( + val originated: LocalDateTime, + val stateAndRef: StateAndRef + ) + + /** + * A small class describing the graphics of a single state. + */ + inner class StateRowGraphic( + val stateRow: StateRow + ) : UIComponent() { + override val root: HBox by fxml("CashStateViewer.fxml") + + val equivLabel: Label by fxid() + val stateIdValueLabel: Label by fxid() + val issuerValueLabel: Label by fxid() + val originatedValueLabel: Label by fxid() + val amountValueLabel: Label by fxid() + val equivValueLabel: Label by fxid() + + val equivAmount: ObservableValue> = reportingExchange.map { + it.second(stateRow.stateAndRef.state.data.amount.withoutIssuer()) + } + + init { + val amountNoIssuer = stateRow.stateAndRef.state.data.amount.withoutIssuer() + val amountFormatter = AmountFormatter.boring + val equivFormatter = AmountFormatter.boring + + equivLabel.textProperty().bind(equivAmount.map { it.token.currencyCode.toString() }) + stateIdValueLabel.text = stateRow.stateAndRef.ref.toString() + issuerValueLabel.text = stateRow.stateAndRef.state.data.amount.token.issuer.toString() + originatedValueLabel.text = stateRow.originated.toString() + amountValueLabel.text = amountFormatter.format(amountNoIssuer) + equivValueLabel.textProperty().bind(equivAmount.map { equivFormatter.format(it) }) + } + } + + /** + * The list of states related to the current selection. If none or the root is selected it's empty, if an issuer or + * currency node is selected it's the relevant states. + */ + private val noSelectionStates = FXCollections.observableArrayList>() + private val selectedViewerNodeStates = ChosenList(selectedViewerNode.map { selection -> + when (selection) { + is SingleRowSelection.None -> noSelectionStates + is SingleRowSelection.Selected -> + when (selection.node) { + CashViewer.ViewerNode.Root -> noSelectionStates + is CashViewer.ViewerNode.IssuerNode -> selection.node.states + is CashViewer.ViewerNode.CurrencyNode -> selection.node.states + } + } + }) + + /** + * We re-display the exchanged sum amount, if we have a selection. + */ + private val noSelectionSumEquiv = reportingCurrency.map { Amount(0, it) } + private val selectedViewerNodeSumEquiv = selectedViewerNode.bind { selection -> + when (selection) { + is SingleRowSelection.None -> noSelectionSumEquiv + is SingleRowSelection.Selected -> + when (selection.node) { + ViewerNode.Root -> noSelectionSumEquiv + is ViewerNode.IssuerNode -> selection.node.sumEquivAmount + is ViewerNode.CurrencyNode -> selection.node.equivAmount + } + } + } + + /** + * We add some extra timestamp data here to the selected states. + * + * TODO update this once we have actual timestamps. + */ + private val stateRows = selectedViewerNodeStates.map { StateRow(LocalDateTime.now(), it) } + + /** + * We only display the right pane if a node is selected in the TreeTable. + */ + private val onlyLeftPaneShown = FXCollections.observableArrayList(leftPane) + private val bothPanesShown = FXCollections.observableArrayList(leftPane, rightPane) + private val panesShown = ChosenList(selectedViewerNode.map { + when (it) { + is SingleRowSelection.None -> onlyLeftPaneShown + is SingleRowSelection.Selected -> bothPanesShown + } + }) + + // Wire up UI + init { + searchCancelImageView.setOnMouseClicked { event: MouseEvent -> + if (event.button == MouseButton.PRIMARY) { + searchCriteriaTextField.text = "" + } + } + + Bindings.bindContent(topSplitPane.items, panesShown) + + totalPositionsLabel.textProperty().bind(Bindings.size(selectedViewerNodeStates).map { + val plural = if (it == 1) "" else "s" + "Total $it position$plural" + }) + + val equivSumLabelFormatter = AmountFormatter.boring + equivSumLabel.textProperty().bind(selectedViewerNodeSumEquiv.map { + equivSumLabelFormatter.format(it) + }) + + Bindings.bindContent(cashStatesList.items, stateRows) + + cashStatesList.setCustomCellFactory { StateRowGraphic(it).root } + + val cellFactory = AmountFormatter.boring.toTreeTableCellFactory>() + + // TODO use smart resize + cashViewerTable.setColumnPrefWidthPolicy { tableWidthWithoutPaddingAndBorder, column -> + Math.floor(tableWidthWithoutPaddingAndBorder.toDouble() / cashViewerTable.columns.size).toInt() + } + + cashViewerTableIssuerCurrency.setCellValueFactory { + val node = it.value.value + when (node) { + ViewerNode.Root -> "".lift() + is ViewerNode.IssuerNode -> node.issuer.toString().lift() + is ViewerNode.CurrencyNode -> node.amount.map { it.token.toString() } + } + } + cashViewerTableLocalCurrency.setCellValueFactory { + val node = it.value.value + when (node) { + ViewerNode.Root -> null.lift() + is ViewerNode.IssuerNode -> null.lift() + is ViewerNode.CurrencyNode -> node.amount.map { it } + } + } + cashViewerTableLocalCurrency.cellFactory = cellFactory + /** + * We must set this, otherwise on sort an exception will be thrown, as it will try to compare Amounts of differing currency + */ + cashViewerTableLocalCurrency.isSortable = false + cashViewerTableEquiv.setCellValueFactory { + val node = it.value.value + when (node) { + ViewerNode.Root -> null.lift() + is ViewerNode.IssuerNode -> node.sumEquivAmount.map { it } + is ViewerNode.CurrencyNode -> node.equivAmount.map { it } + } + } + cashViewerTableEquiv.cellFactory = cellFactory + cashViewerTableEquiv.textProperty().bind(reportingCurrency.map { "$it Equiv" }) + + cashViewerTable.root = TreeItem(ViewerNode.Root) + val children: List> = cashViewerTable.root.children + Bindings.bindContent(children, cashViewerIssueNodes) + + cashViewerTable.root.isExpanded = true + cashViewerTable.isShowRoot = false + + // TODO Think about i18n! + totalMatchingLabel.textProperty().bind(Bindings.size(cashViewerIssueNodes).map { + val plural = if (it == 1) "" else "s" + "Total $it matching issuer$plural" + }) + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/Header.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/Header.kt new file mode 100644 index 0000000000..927f5f1259 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/Header.kt @@ -0,0 +1,63 @@ +package com.r3corda.explorer.views + +import com.r3corda.client.model.observableValue +import com.r3corda.explorer.model.SelectedView +import com.r3corda.explorer.model.TopLevelModel +import javafx.beans.value.ObservableValue +import javafx.scene.control.Button +import javafx.scene.control.Label +import javafx.scene.image.Image +import javafx.scene.image.ImageView +import javafx.scene.layout.VBox +import org.fxmisc.easybind.EasyBind +import tornadofx.View + +class Header : View() { + override val root: VBox by fxml() + + private val sectionIcon: ImageView by fxid() + private val sectionIconContainer: VBox by fxid() + private val sectionLabel: Label by fxid() + private val debugNextButton: Button by fxid() + private val debugGoStopButton: Button by fxid() + + private val selectedView: ObservableValue by observableValue(TopLevelModel::selectedView) + + private val homeImage = Image("/com/r3corda/explorer/images/home.png") + private val cashImage = Image("/com/r3corda/explorer/images/cash.png") + private val transactionImage = Image("/com/r3corda/explorer/images/tx.png") + + init { + sectionLabel.textProperty().bind(EasyBind.map(selectedView) { + when (it) { + SelectedView.Home -> "Home" + SelectedView.Cash -> "Cash" + SelectedView.Transaction -> "Transactions" + null -> "Home" + } + }) + + sectionIcon.imageProperty().bind(EasyBind.map(selectedView) { + when (it) { + SelectedView.Home -> homeImage + SelectedView.Cash -> cashImage + SelectedView.Transaction -> transactionImage + null -> homeImage + } + }) + + // JavaFX bugs and doesn't invalidate the wrapping Box's height if the icon fit height is first set to + // unbounded (0.0) - which is what the label's height is initially, so we set it to 1.0 instead + val secionLabelHeightNonZero = EasyBind.map(sectionLabel.heightProperty()) { + if (it == 0.0) { + 1.0 + } else { + it.toDouble() + } + } + + sectionIconContainer.minWidthProperty().bind(secionLabelHeightNonZero) + sectionIcon.fitWidthProperty().bind(secionLabelHeightNonZero) + sectionIcon.fitHeightProperty().bind(sectionIcon.fitWidthProperty()) + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/Home.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/Home.kt new file mode 100644 index 0000000000..8c8cb7fdf0 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/Home.kt @@ -0,0 +1,68 @@ +package com.r3corda.explorer.views + +import com.r3corda.client.fxutils.AmountBindings +import com.r3corda.client.fxutils.map +import com.r3corda.client.model.* +import com.r3corda.contracts.asset.Cash +import com.r3corda.core.contracts.StateAndRef +import com.r3corda.core.contracts.withoutIssuer +import com.r3corda.explorer.formatters.AmountFormatter +import com.r3corda.explorer.model.SelectedView +import com.r3corda.explorer.model.SettingsModel +import com.r3corda.explorer.model.TopLevelModel +import javafx.beans.binding.Bindings +import javafx.beans.value.ObservableValue +import javafx.beans.value.WritableValue +import javafx.collections.ObservableList +import javafx.scene.control.Label +import javafx.scene.control.TitledPane +import javafx.scene.input.MouseButton +import javafx.scene.layout.TilePane +import org.fxmisc.easybind.EasyBind +import tornadofx.View +import java.util.* + + +class Home : View() { + override val root: TilePane by fxml() + + private val ourCashPane: TitledPane by fxid() + private val ourCashLabel: Label by fxid() + + private val ourTransactionsPane: TitledPane by fxid() + private val ourTransactionsLabel: Label by fxid() + + private val selectedView: WritableValue by writableValue(TopLevelModel::selectedView) + private val cashStates: ObservableList> by observableList(ContractStateModel::cashStates) + private val gatheredTransactionDataList: ObservableList + by observableListReadOnly(GatheredTransactionDataModel::gatheredTransactionDataList) + private val reportingCurrency: ObservableValue by observableValue(SettingsModel::reportingCurrency) + private val exchangeRate: ObservableValue by observableValue(ExchangeRateModel::exchangeRate) + + private val sumAmount = AmountBindings.sumAmountExchange( + cashStates.map { it.state.data.amount.withoutIssuer() }, + reportingCurrency, + exchangeRate + ) + + init { + val formatter = AmountFormatter.boring + + ourCashLabel.textProperty().bind(sumAmount.map { formatter.format(it) }) + ourCashPane.setOnMouseClicked { clickEvent -> + if (clickEvent.button == MouseButton.PRIMARY) { + selectedView.value = SelectedView.Cash + } + } + + ourTransactionsLabel.textProperty().bind( + Bindings.size(gatheredTransactionDataList).map { it.toString() } + ) + ourTransactionsPane.setOnMouseClicked { clickEvent -> + if (clickEvent.button == MouseButton.PRIMARY) { + selectedView.value = SelectedView.Transaction + } + } + + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/TopLevel.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/TopLevel.kt new file mode 100644 index 0000000000..0509ca5dbe --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/TopLevel.kt @@ -0,0 +1,49 @@ +package com.r3corda.explorer.views + +import com.r3corda.client.model.objectProperty +import com.r3corda.explorer.model.SelectedView +import com.r3corda.explorer.model.TopLevelModel +import javafx.beans.property.ObjectProperty +import javafx.scene.input.KeyCode +import javafx.scene.input.KeyEvent +import javafx.scene.layout.BorderPane +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import org.fxmisc.easybind.EasyBind +import tornadofx.View + +class TopLevel : View() { + override val root: VBox by fxml() + val selectionBorderPane: BorderPane by fxid() + + private val header: Header by inject() + private val home: Home by inject() + private val cash: CashViewer by inject() + private val transaction: TransactionViewer by inject() + + // Note: this is weirdly very important, as it forces the initialisation of Views. Therefore this is the entry + // point to the top level observable/stream wiring! Any events sent before this init may be lost! + private val homeRoot = home.root + private val cashRoot = cash.root + private val transactionRoot = transaction.root + + private fun getView(selection: SelectedView) = when (selection) { + SelectedView.Home -> homeRoot + SelectedView.Cash -> cashRoot + SelectedView.Transaction -> transactionRoot + } + val selectedView: ObjectProperty by objectProperty(TopLevelModel::selectedView) + + init { + VBox.setVgrow(selectionBorderPane, Priority.ALWAYS) + selectionBorderPane.centerProperty().bind(EasyBind.map(selectedView) { getView(it) }) + + primaryStage.addEventHandler(KeyEvent.KEY_RELEASED) { keyEvent -> + if (keyEvent.code == KeyCode.ESCAPE) { + selectedView.value = SelectedView.Home + } + } + + root.children.add(0, header.root) + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt new file mode 100644 index 0000000000..e03d3a9192 --- /dev/null +++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt @@ -0,0 +1,399 @@ +package com.r3corda.explorer.views + +import com.r3corda.client.fxutils.ChosenList +import com.r3corda.client.fxutils.bind +import com.r3corda.client.fxutils.lift +import com.r3corda.client.fxutils.map +import com.r3corda.client.model.* +import com.r3corda.contracts.asset.Cash +import com.r3corda.core.contracts.* +import com.r3corda.core.crypto.Party +import com.r3corda.core.crypto.SecureHash +import com.r3corda.core.crypto.toStringShort +import com.r3corda.core.transactions.LedgerTransaction +import com.r3corda.explorer.AmountDiff +import com.r3corda.explorer.formatters.AmountFormatter +import com.r3corda.explorer.formatters.Formatter +import com.r3corda.explorer.formatters.NumberFormatter +import com.r3corda.explorer.model.IdentityModel +import com.r3corda.explorer.model.ReportingCurrencyModel +import com.r3corda.explorer.sign +import com.r3corda.explorer.ui.* +import com.r3corda.node.services.monitor.ServiceToClientEvent +import javafx.beans.binding.Bindings +import javafx.beans.value.ObservableValue +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.geometry.Insets +import javafx.scene.Node +import javafx.scene.control.* +import javafx.scene.layout.Background +import javafx.scene.layout.BackgroundFill +import javafx.scene.layout.CornerRadii +import javafx.scene.layout.VBox +import javafx.scene.paint.Color +import org.fxmisc.easybind.EasyBind +import tornadofx.View +import java.security.PublicKey +import java.time.Instant +import java.util.* + +class TransactionViewer: View() { + override val root: VBox by fxml() + + val topSplitPane: SplitPane by fxid() + + // Top half (transactions table) + private val transactionViewTable: TableView by fxid() + private val transactionViewTransactionId: TableColumn by fxid() + private val transactionViewFiberId: TableColumn by fxid() + private val transactionViewClientUuid: TableColumn by fxid() + private val transactionViewTransactionStatus: TableColumn by fxid() + private val transactionViewProtocolStatus: TableColumn by fxid() + private val transactionViewStateMachineStatus: TableColumn by fxid() + private val transactionViewCommandTypes: TableColumn by fxid() + private val transactionViewTotalValueEquiv: TableColumn> by fxid() + + // Bottom half (details) + private val contractStatesTitledPane: TitledPane by fxid() + + private val contractStatesInputsCountLabel: Label by fxid() + private val contractStatesInputStatesTable: TableView by fxid() + private val contractStatesInputStatesId: TableColumn by fxid() + private val contractStatesInputStatesType: TableColumn> by fxid() + private val contractStatesInputStatesOwner: TableColumn by fxid() + private val contractStatesInputStatesLocalCurrency: TableColumn by fxid() + private val contractStatesInputStatesAmount: TableColumn by fxid() + private val contractStatesInputStatesEquiv: TableColumn> by fxid() + + private val contractStatesOutputsCountLabel: Label by fxid() + private val contractStatesOutputStatesTable: TableView by fxid() + private val contractStatesOutputStatesId: TableColumn by fxid() + private val contractStatesOutputStatesType: TableColumn> by fxid() + private val contractStatesOutputStatesOwner: TableColumn by fxid() + private val contractStatesOutputStatesLocalCurrency: TableColumn by fxid() + private val contractStatesOutputStatesAmount: TableColumn by fxid() + private val contractStatesOutputStatesEquiv: TableColumn> by fxid() + + private val signaturesTitledPane: TitledPane by fxid() + private val signaturesList: ListView by fxid() + + private val lowLevelEventsTitledPane: TitledPane by fxid() + private val lowLevelEventsTable: TableView by fxid() + private val lowLevelEventsTimestamp: TableColumn by fxid() + private val lowLevelEventsEvent: TableColumn by fxid() + + private val matchingTransactionsLabel: Label by fxid() + + // Inject data + private val gatheredTransactionDataList: ObservableList + by observableListReadOnly(GatheredTransactionDataModel::gatheredTransactionDataList) + private val reportingExchange: ObservableValue) -> Amount>> + by observableValue(ReportingCurrencyModel::reportingExchange) + private val myIdentity: ObservableValue by observableValue(IdentityModel::myIdentity) + + /** + * This is what holds data for a single transaction node. Note how a lot of these are nullable as we often simply don't + * have the data. + */ + data class ViewerNode( + val transactionId: ObservableValue, + val fiberId: ObservableValue, + val clientUuid: ObservableValue, + val originator: ObservableValue, + val transactionStatus: ObservableValue, + val stateMachineStatus: ObservableValue, + val protocolStatus: ObservableValue, + val statusUpdated: ObservableValue, + val commandTypes: ObservableValue>>, + val totalValueEquiv: ObservableValue?>, + val transaction: ObservableValue, + val allEvents: ObservableList + ) + + /** + * Holds information about a single input/output state, to be displayed in the [contractStatesTitledPane] + */ + data class StateNode( + val transactionState: TransactionState<*>, + val stateRef: StateRef + ) + + /** + * We map the gathered data about transactions almost one-to-one to the nodes. + */ + private val viewerNodes = gatheredTransactionDataList.map { + ViewerNode( + transactionId = it.transaction.map { it?.id }, + fiberId = it.fiberId, + clientUuid = it.uuid, + /** + * We can't really do any better based on uuid, we need to store explicit data for this TODO + */ + originator = it.uuid.map { uuid -> + if (uuid == null) { + "Someone" + } else { + "Us" + } + }, + transactionStatus = it.status, + protocolStatus = it.protocolStatus, + stateMachineStatus = it.stateMachineStatus, + statusUpdated = it.lastUpdate, + commandTypes = it.transaction.map { + val commands = mutableSetOf>() + it?.commands?.forEach { + commands.add(it.value.javaClass) + } + commands + }, + totalValueEquiv = ::calculateTotalEquiv.lift(myIdentity, reportingExchange, it.transaction), + transaction = it.transaction, + allEvents = it.allEvents + ) + } + + /** + * The detail panes are only filled out if a transaction is selected + */ + private val selectedViewerNode = transactionViewTable.singleRowSelection() + private val selectedTransaction = selectedViewerNode.bind { + when (it) { + is SingleRowSelection.None -> null.lift() + is SingleRowSelection.Selected -> it.node.transaction + } + } + + private val inputStateNodes = ChosenList(selectedTransaction.map { + if (it == null) { + FXCollections.emptyObservableList() + } else { + FXCollections.observableArrayList(it.inputs.map { StateNode(it.state, it.ref) }) + } + }) + + private val outputStateNodes = ChosenList(selectedTransaction.map { + if (it == null) { + FXCollections.emptyObservableList() + } else { + FXCollections.observableArrayList(it.outputs.mapIndexed { index, transactionState -> + StateNode(transactionState, StateRef(it.id, index)) + }) + } + }) + + private val signatures = ChosenList(selectedTransaction.map { + if (it == null) { + FXCollections.emptyObservableList() + } else { + FXCollections.observableArrayList(it.mustSign) + } + }) + + private val lowLevelEvents = ChosenList(selectedViewerNode.map { + when (it) { + is SingleRowSelection.None -> FXCollections.emptyObservableList() + is SingleRowSelection.Selected -> it.node.allEvents + } + }) + + /** + * We only display the detail panes if there is a node selected. + */ + private val allNodesShown = FXCollections.observableArrayList( + transactionViewTable, + contractStatesTitledPane, + signaturesTitledPane, + lowLevelEventsTitledPane + ) + private val onlyTransactionsTableShown = FXCollections.observableArrayList( + transactionViewTable + ) + private val topSplitPaneNodesShown = ChosenList( + selectedViewerNode.map { selection -> + if (selection is SingleRowSelection.None<*>) { + onlyTransactionsTableShown + } else { + allNodesShown + } + }) + + /** + * Both input and output state tables look the same, so we each up with [wireUpStatesTable] + */ + private fun wireUpStatesTable( + states: ObservableList, + statesCountLabel: Label, + statesTable: TableView, + statesId: TableColumn, + statesType: TableColumn>, + statesOwner: TableColumn, + statesLocalCurrency: TableColumn, + statesAmount: TableColumn, + statesEquiv: TableColumn> + ) { + statesCountLabel.textProperty().bind(Bindings.size(states).map { "$it" }) + + Bindings.bindContent(statesTable.items, states) + + statesId.setCellValueFactory { it.value.stateRef.toString().lift() } + statesType.setCellValueFactory { it.value.transactionState.data.javaClass.lift() } + statesOwner.setCellValueFactory { + val state = it.value.transactionState.data + if (state is OwnableState) { + state.owner.toStringShort().lift() + } else { + "???".lift() + } + } + statesLocalCurrency.setCellValueFactory { + val state = it.value.transactionState.data + if (state is Cash.State) { + state.amount.token.product.lift() + } else { + null.lift() + } + } + statesAmount.setCellValueFactory { + val state = it.value.transactionState.data + if (state is Cash.State) { + state.amount.quantity.lift() + } else { + null.lift() + } + } + statesAmount.cellFactory = NumberFormatter.boringLong.toTableCellFactory() + statesEquiv.setCellValueFactory { + val state = it.value.transactionState.data + if (state is Cash.State) { + reportingExchange.map { exchange -> + exchange.second(state.amount.withoutIssuer()) + } + } else { + null.lift() + } + } + statesEquiv.cellFactory = AmountFormatter.boring.toTableCellFactory() + } + + init { + Bindings.bindContent(topSplitPane.items, topSplitPaneNodesShown) + + // Transaction table + Bindings.bindContent(transactionViewTable.items, viewerNodes) + + transactionViewTable.setColumnPrefWidthPolicy { tableWidthWithoutPaddingAndBorder, column -> + Math.floor(tableWidthWithoutPaddingAndBorder.toDouble() / transactionViewTable.columns.size).toInt() + } + + transactionViewTransactionId.setCellValueFactory { it.value.transactionId.map { "${it ?: ""}" } } + transactionViewFiberId.setCellValueFactory { it.value.fiberId.map { "${it?: ""}" } } + transactionViewClientUuid.setCellValueFactory { it.value.clientUuid.map { "${it ?: ""}" } } + transactionViewProtocolStatus.setCellValueFactory { it.value.protocolStatus.map { "${it ?: ""}" } } + transactionViewTransactionStatus.setCellValueFactory { it.value.transactionStatus } + transactionViewTransactionStatus.setCustomCellFactory { + val label = Label() + val backgroundFill = when (it) { + is TransactionCreateStatus.Started -> BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY) + is TransactionCreateStatus.Failed -> BackgroundFill(Color.SALMON, CornerRadii.EMPTY, Insets.EMPTY) + null -> BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY) + } + label.background = Background(backgroundFill) + label.text = "$it" + label + } + transactionViewStateMachineStatus.setCellValueFactory { it.value.stateMachineStatus } + transactionViewStateMachineStatus.setCustomCellFactory { + val label = Label() + val backgroundFill = when (it) { + is StateMachineStatus.Added -> BackgroundFill(Color.LIGHTYELLOW, CornerRadii.EMPTY, Insets.EMPTY) + is StateMachineStatus.Removed -> BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY) + null -> BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY) + } + label.background = Background(backgroundFill) + label.text = "$it" + label + } + + transactionViewCommandTypes.setCellValueFactory { + it.value.commandTypes.map { it.map { it.simpleName }.joinToString(",") } + } + transactionViewTotalValueEquiv.setCellValueFactory> { it.value.totalValueEquiv } + transactionViewTotalValueEquiv.cellFactory = object : Formatter> { + override fun format(value: AmountDiff) = + "${value.positivity.sign}${AmountFormatter.boring.format(value.amount)}" + }.toTableCellFactory() + + // Contract states + wireUpStatesTable( + inputStateNodes, + contractStatesInputsCountLabel, + contractStatesInputStatesTable, + contractStatesInputStatesId, + contractStatesInputStatesType, + contractStatesInputStatesOwner, + contractStatesInputStatesLocalCurrency, + contractStatesInputStatesAmount, + contractStatesInputStatesEquiv + ) + wireUpStatesTable( + outputStateNodes, + contractStatesOutputsCountLabel, + contractStatesOutputStatesTable, + contractStatesOutputStatesId, + contractStatesOutputStatesType, + contractStatesOutputStatesOwner, + contractStatesOutputStatesLocalCurrency, + contractStatesOutputStatesAmount, + contractStatesOutputStatesEquiv + ) + + // Signatures + Bindings.bindContent(signaturesList.items, signatures) + signaturesList.cellFactory = object : Formatter { + override fun format(value: PublicKey) = value.toStringShort() + }.toListCellFactory() + + // Low level events + Bindings.bindContent(lowLevelEventsTable.items, lowLevelEvents) + lowLevelEventsTimestamp.setCellValueFactory { it.value.time.lift() } + lowLevelEventsEvent.setCellValueFactory { it.value.lift() } + lowLevelEventsTable.setColumnPrefWidthPolicy { tableWidthWithoutPaddingAndBorder, column -> + Math.floor(tableWidthWithoutPaddingAndBorder.toDouble() / lowLevelEventsTable.columns.size).toInt() + } + + matchingTransactionsLabel.textProperty().bind(EasyBind.map(Bindings.size(viewerNodes)) { + "$it matching transaction${if (it == 1) "" else "s"}" + }) + } +} + +/** + * We calculate the total value by subtracting relevant input states and adding relevant output states, as long as they're cash + */ +private fun calculateTotalEquiv( + identity: Party, + reportingCurrencyExchange: Pair) -> Amount>, + transaction: LedgerTransaction?): AmountDiff? { + if (transaction == null) { + return null + } + var sum = 0L + val (reportingCurrency, exchange) = reportingCurrencyExchange + val publicKey = identity.owningKey + transaction.inputs.forEach { + val contractState = it.state.data + if (contractState is Cash.State && publicKey == contractState.owner) { + sum -= exchange(contractState.amount.withoutIssuer()).quantity + } + } + transaction.outputs.forEach { + val contractState = it.data + if (contractState is Cash.State && publicKey == contractState.owner) { + sum += exchange(contractState.amount.withoutIssuer()).quantity + } + } + return AmountDiff.fromLong(sum, reportingCurrency) +} + diff --git a/explorer/src/main/resources/com/r3corda/explorer/css/notification-bar.css b/explorer/src/main/resources/com/r3corda/explorer/css/notification-bar.css new file mode 100644 index 0000000000..32abc81f73 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/css/notification-bar.css @@ -0,0 +1,42 @@ +.notification-bar { + -fx-background-color: linear-gradient(to bottom, black, darkslategray); +} + +.notification-bar > .notification-bar-item { + -fx-padding: 10; +} + +.notification-bar > .notification-bar-item > Label { + -fx-text-fill: white; + -fx-font-weight: bold; + -fx-font-size: 13; +} + +.notification-bar > .notification-bar-item > .progress-bar > .bar { + -fx-padding: 8; +} + +.notification-bar > .notification-bar-item > .progress-bar > .track { + -fx-opacity: 0.0; +} + +.notification-bar > .notification-bar-item > .button { + -fx-base: orange; + -fx-font-weight: bold; + -fx-font-size: 12; + -fx-text-fill: white; + -fx-background-insets: 1; + -fx-background-radius: 5; +} + +.thin-progress-bar > .bar { + -fx-padding: 8; +} + +.thin-progress-bar > .track { + -fx-background-color: #bce7f5; + -fx-background-insets: 3 3 4 3; + /*-fx-background-radius: 0.583em; *//* 7 */ + -fx-background-radius: 2; + -fx-padding: 8; +} \ No newline at end of file diff --git a/explorer/src/main/resources/com/r3corda/explorer/css/shell.css b/explorer/src/main/resources/com/r3corda/explorer/css/shell.css new file mode 100644 index 0000000000..33cf2bdf5c --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/css/shell.css @@ -0,0 +1,143 @@ +.root { + -fx-font-family: "Roboto Light", sans-serif; + -fx-font-size: 12pt; + /* Setting the background colour explicitly is required for a correct fade/blur animation. */ + -fx-background-color: white; +} + +.sidebar { + -fx-background-color: #494949; +} + +.sidebar-right-shadow { + -fx-background-color: linear-gradient(to left, #1c1c1c, #494949); + -fx-border-color: black; + -fx-border-width: 0 0.1em 0 0; + -fx-padding: 0 0.2em 0 0.2em; +} + +.sidebar-button { + -fx-base: transparent; + -fx-background-color: transparent; + -fx-text-fill: white; + -fx-graphic-text-gap: 1em; + -fx-cursor: hand; + -fx-effect: dropshadow(three-pass-box, black, 6, 0.0, 0, 0); +} + +.sidebar-button:selected { + -fx-background-color: grey; +} + +.sidebar-icon { + -fx-font-size: 25pt; +} + +.modal-window { + -fx-background-color: white; + -fx-background-radius: 5; + -fx-effect: dropshadow(three-pass-box, black, 10, 0.0, 0, 0); +} + +.modal-window > .title-bar { + -fx-background-radius: 5 5 0 0; + -fx-background-color: #3c777b; + -fx-alignment: center-left; +} + +.modal-window > .title-bar > Button { + -fx-base: transparent; + -fx-background-color: transparent; + -fx-cursor: hand; +} + +.modal-window > .title-bar > Button:hover { + -fx-base: #3c777b; + -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color; + -fx-text-fill: white; +} + +.modal-window > .title-bar > Label { + -fx-text-fill: white; + -fx-font-size: 120%; +} + +.large-font { + -fx-font-size: 200%; +} + +/******************************************************************************************************************** + * + * Buttons + * + */ + +.flat-button { + -fx-background-color: white; + -fx-padding: 0 0 0 0; + -fx-font-size: 10pt; +} + +.flat-button:hover { + -fx-underline: true; + -fx-cursor: hand; +} + +.flat-button:focused { + -fx-font-weight: bold; +} + +.fat-buttons { + -fx-spacing: 15.0; +} + +.fat-buttons Button { + -fx-padding: 10 15 10 15; + -fx-min-width: 100; + -fx-font-weight: bold; + -fx-base: whitesmoke; +} + +.fat-buttons Button:default { + -fx-base: orange; + -fx-text-fill: white; +} + +.fat-buttons Button:cancel { + -fx-background-color: white; + -fx-background-insets: 1; + -fx-border-color: lightgray; + -fx-border-radius: 3; + -fx-text-fill: black; +} + +.fat-buttons Button:cancel:hover { + -fx-base: white; + -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color; + -fx-text-fill: black; +} + +/** take out the focus ring */ +.no-focus-button:focused { + -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color; + -fx-background-insets: 0 0 -1 0, 0, 1, 2; + -fx-background-radius: 3px, 3px, 2px, 1px; +} + +.blue-button { + -fx-base: lightblue; + -fx-text-fill: darkslategrey; +} + +.blue-button:disabled { + -fx-text-fill: white; +} + +.green-button { + -fx-base: #62c462; + -fx-text-fill: darkslategrey; +} + +.green-button:disabled { + -fx-text-fill: white; +} \ No newline at end of file diff --git a/explorer/src/main/resources/com/r3corda/explorer/css/wallet.css b/explorer/src/main/resources/com/r3corda/explorer/css/wallet.css new file mode 100644 index 0000000000..1b2f76c43b --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/css/wallet.css @@ -0,0 +1,400 @@ +#topLevel.root { + -fx-background-image:url('../images/r3bg.png'); + -fx-background-size: cover; + -fx-background-repeat:no-repeat; + -fx-base:white; +} + +#cashViewer { + -fx-background-color: transparent; +} + +#cashViewerTable { + -fx-background-color: transparent; +} + +#cashViewerTable .tree-table-row-cell { + -fx-background-color: transparent; +} + +.root { + -fx-padding:5px; +} + +.root { + -fx-padding:5px; +} + +.dialog-pane { + -fx-background-color:rgba(255,255,255,0.7); + -fx-background-radius:2px; + -fx-border-radius: 2px; + -fx-border-color: rgb(20,136,204); +} + + +.nested-column-header, .nested-column-header { + -fx-background-color:transparent; + -fx-wrap-text:true; + -fx-border-color:transparent; +} + + +.text-field, +.table-column, +.label, +.title, +.combo-box, +.button, +.split-menu-button, +.choice-box { + -fx-font-family:Effra; + -fx-font-size:1em; + -fx-text-fill:rgb(63,63,63); + -fx-font-smoothing-type: gray; +} + +.text-highlight { + -fx-text-fill:rgb(20,136,204); +} + +.context-menu { + -fx-background-color:rgba(255,255,255,0.9); +} + +.titled-pane .content, +.split-menu-button .label, +.split-menu-button .arrow-button, +.titled-pane .split-pane, .scroll-pane { + -fx-background-color:transparent; +} + + +.text-field, +.tree-table-view, +.table-view, +.accordion, +.combo-box, +.context-menu, +.button, +.split-menu-button, +.choice-box, +.titled-pane .title { + -fx-border-color:rgb(150,150,150); + -fx-border-width:1px; + +} + +.text-field:focused, +.tree-table-view:focused, +.table-view:focused, +.accordion:focused, +.combo-box:focused, +.context-menu:focused, +.button:focused, +.split-menu-button:focused, +.text-field:hover, +.button:hover, +.split-menu-button:hover, +.choice-box:hover, +.titled-pane:hover .title { + -fx-border-color:rgb(20,136,204); +} + +.split-menu-button:pressed, +.button:pressed, +.choice-box:pressed, +.titled-pane:expanded .title { + -fx-background-color:rgb(20,136,204); + -fx-text-fill:white; +} + +.titled-pane:expanded .title .text { + -fx-fill:white; +} +.titled-pane .title,.titled-pane .title:hover { + -fx-border-width:0.5px; +} + +.text-field, .combo-box, .choice-box, .password-field { + -fx-background-color:rgba(255,255,255,0.5); + -fx-background-radius:2px; + -fx-border-radius: 2px; + + +} + +/* switch off highlighting for text-field when it's inside a combo-box */ +.combo-box .text-field, .combo-box .text-field:hover, .combo-box .text-field:focused { + -fx-border-color:transparent; +} + /* table formatting */ + +.column-header-background, +.table-column, +.tree-table-row-cell, .column-header-background .filler { + -fx-background-color:transparent; + -fx-label-padding:3px; + + + } + +.nested-column-header .label { -fx-wrap-text:true} + +.table-column { -fx-border-style:solid; + -fx-border-color:rgb(216,216,216); /*t r b l */ + + -fx-border-width:0.5px; + -fx-border-insets: 1.5px; + -fx-background-insets: 2px; + } + + + .tree-table-row-cell:even .table-column, + .table-row-cell:even .table-column, + .title, .split-menu-button, .button { + -fx-background-color: rgba(20,136,204,0.2); +} + + .table-row-cell:selected, .tree-table-row-cell:selected { + -fx-background-color:transparent; + } + .tree-table-row-cell:selected .table-column, + .tree-table-row-cell:focused .table-column, + .table-row-cell:selected .table-column, + .table-row-cell:focused .table-column { + -fx-background-color:rgba(20,136,204,0.8); + + +} +.bad .text { + -fx-fill:rgb(236,29,36); +} + +.table-row-cell:focused .table-column .text, +.tree-table-row-cell:focused .table-column .text { + -fx-fill:white; +} + +.table-column:hover, +.table-row-cell:hover .first-column, +.table-row-cell:hover .second-column, +.tree-table-row-cell:hover .first-column, +.tree-table-row-cell:hover .second-column { + -fx-border-color:rgb(20,136,204); +} + +.tree-table-view .column-header-background .nested-column-header .table-column, +.table-view .column-header-background .nested-column-header .table-column { + -fx-border-color:transparent; +} + +/* Special formatting - columns to be presented with no join between them */ + +.first-column { + -fx-border-width:0.5px 0px 0.5px 0.5px; + -fx-border-insets: 1.5px 0px 1.5px 1.5px; + -fx-background-insets: 2px 0px 2px 2px; +} + + +.second-column { + -fx-border-width:0.5px 0.5px 0.5px 0px; + -fx-border-insets: 1.5px 1.5px 1.5px 0px; + -fx-background-insets: 2px 2px 2px 0px; +} + + +/* highlighting where the user has typed a key */ +.tree-table-view text-area, .table-view text-area{ + -fx-font-weight:bold; + -fx-fill:rgb(20,136,204); + +} + +.tree-table-row-cell:selected .table-column text-area, +.table-row-cell:selected .table-column text-area +{ + -fx-font-weight:bold; + -fx-fill:rgb(255,255,255); +} + + +/* labels */ +.dialog-pane .header-panel .label .text{ + -fx-font-size:1em; + -fx-fill:rgb(20,136,204); +} + +#headline, .headline { + -fx-font-size:2.4em; +} +#subline, .subline { + -fx-font-size:1.4em; +} +#headline, #subline { + -fx-text-fill:rgb(65,65,65); + -fx-padding:0px; + +} + +/* search boxes */ +.search { + -fx-background-image:url('../images/search.png'); + -fx-background-size:Auto 16px; + -fx-background-repeat:no-repeat; + -fx-background-position:8px center; + -fx-padding:5px 5px 5px 30px; + -fx-background-radius: 2px; + -fx-border-radius: 2px; +} + +.search-clear { + -fx-image:url('../images/clear_inactive.png'); +} + +.search-clear:hover { + -fx-image:url('../images/clear.png'); +} + +.split-menu-button, .button, .choice-box { + -fx-background-radius:2px; + -fx-border-radius: 2px; + -fx-border-insets: 0.5px; + -fx-background-insets:0.5px; + +} + +.tree-table-row-cell .monetary-value, .monetary-value .label, .table-row-cell .monetary-value { + -fx-alignment:center-right; +} + + +/* split panes */ +.split-pane-divider { + -fx-background-color: transparent; + -fx-border-color: rgb(160,160,160); + -fx-border-width: 0 0 0 0.5px +} + +/* Dashboard tiles */ + +.tile,.tile-user { + -fx-padding: 10px; + -fx-pref-height:200px; -fx-pref-width:200px; + + +} +.tile .title, .tile:expanded .title, +.tile-user .title, .tile-user:expanded .title { + -fx-alignment:center-right; + -fx-font-size:1.4em; + -fx-font-weight:bold; + -fx-cursor:hand; + -fx-background-radius:2px 2px 0 0; + -fx-border-radius: 2px 2px 0 0; + -fx-border-width:1px 1px 0 1px; + -fx-background-color: rgba(255,255,255,0.5); + -fx-border-color:rgb(160,160,160); /*t r b l */ + +} + +.tile .title .text, .tile:expanded .title .text, +.tile-user .title .text, .tile-user:expanded .title .text { + -fx-fill:rgb(65,65,65); +} +.tile .content, +.tile-user .content { + -fx-background-color: rgba(255,255,255,0.7); + -fx-background-size:Auto 90%; + -fx-background-repeat:no-repeat; + -fx-background-position:center center; + -fx-cursor:hand; + -fx-background-radius:0 0 2px 2px; + -fx-border-radius: 0 0 2px 2px; + -fx-padding:0px; + -fx-alignment:bottom-right; + -fx-border-color:rgb(150,150,150); /*t r b l */ +} +.tile .label, +.tile-user .label { + -fx-font-size:2.4em; + -fx-padding:20px; + -fx-text-fill:rgb(65,65,65); + -fx-font-weight:normal; + -fx-text-alignment:right; + +} + +.tile:hover .label, +.tile-user:hover .label { + -fx-padding:24px; +} + +.tile:hover .content, .tile:hover .title, +.tile-user:hover .content, .tile-user:hover .title { + -fx-border-color:rgb(20,136,204); + -fx-background-color: rgb(20,136,204); + +} +.tile:hover, .tile-user:hover { + -fx-padding:4px; +} + +.tile:hover .label, .tile:hover .label .text, .tile:hover .title .text { + -fx-text-fill:rgb(255,255,255); + -fx-fill:rgb(255,255,255); + -fx-font-weight:bold; + -fx-effect:none; +} + +#tile_cash .content { + -fx-background-image:url('../images/cash_lrg.png'); +} +#tile_debtors .content { + -fx-background-image:url('../images/outflow_lrg.png'); +} +#tile_creditors .content { + -fx-background-image:url('../images/inflow_lrg.png'); +} +#tile_tx .content { + -fx-background-image:url('../images/tx_lrg.png'); +} + +#tile_cpty .content { + -fx-background-image:url('../images/cpty_lrg.png'); +} + +.tile-user .content { + -fx-background-image:url('../images/user_b.png'); +} +.tile-user-test-man .content { + -fx-background-image:url('../images/man1.png'); + -fx-background-size:cover; +} +.tile-user-test-woman .content { + -fx-background-image:url('../images/woman1.png'); + -fx-background-size:cover; +} + +.tile-user .label { + -fx-background-color:rgba(255,255,255,0.7); +} + +.tile-user:hover .title, .tile-user:hover .content { + -fx-background-color:rgba(255,255,255,0.7); +} + +.counterparty { + -fx-background-image:url('../images/inst_128.png'); + -fx-background-size:Auto 16px; + -fx-background-repeat: no-repeat; + -fx-background-position:0px center; + -fx-padding:0 0 0 20px; +} + +.state-panel{ + -fx-background-color: rgba(255,255,255,0.7); + -fx-border-color:rgb(150,150,150); + -fx-insets:5px +} diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/arrow_l.png b/explorer/src/main/resources/com/r3corda/explorer/images/arrow_l.png new file mode 100644 index 0000000000..d0514f36d2 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/arrow_l.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/arrow_r.png b/explorer/src/main/resources/com/r3corda/explorer/images/arrow_r.png new file mode 100644 index 0000000000..1c112f2c15 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/arrow_r.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/cash.png b/explorer/src/main/resources/com/r3corda/explorer/images/cash.png new file mode 100644 index 0000000000..bf417a6bdf Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/cash.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/cash_lrg.png b/explorer/src/main/resources/com/r3corda/explorer/images/cash_lrg.png new file mode 100644 index 0000000000..5c38bc5d73 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/cash_lrg.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/clear.png b/explorer/src/main/resources/com/r3corda/explorer/images/clear.png new file mode 100644 index 0000000000..e38c3f0cac Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/clear.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/clear_inactive.png b/explorer/src/main/resources/com/r3corda/explorer/images/clear_inactive.png new file mode 100644 index 0000000000..af2f5fb1e6 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/clear_inactive.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/cpty_lrg.png b/explorer/src/main/resources/com/r3corda/explorer/images/cpty_lrg.png new file mode 100644 index 0000000000..02db5cad23 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/cpty_lrg.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/home.png b/explorer/src/main/resources/com/r3corda/explorer/images/home.png new file mode 100644 index 0000000000..f514c86570 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/home.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/inflow_lrg.png b/explorer/src/main/resources/com/r3corda/explorer/images/inflow_lrg.png new file mode 100644 index 0000000000..38962088a4 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/inflow_lrg.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/inst.png b/explorer/src/main/resources/com/r3corda/explorer/images/inst.png new file mode 100644 index 0000000000..7e13c33f1b Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/inst.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/inst_128.png b/explorer/src/main/resources/com/r3corda/explorer/images/inst_128.png new file mode 100644 index 0000000000..079f3fc66b Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/inst_128.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/man1.png b/explorer/src/main/resources/com/r3corda/explorer/images/man1.png new file mode 100644 index 0000000000..3b778fcd8c Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/man1.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/outflow_lrg.png b/explorer/src/main/resources/com/r3corda/explorer/images/outflow_lrg.png new file mode 100644 index 0000000000..de75ca8223 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/outflow_lrg.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/search.png b/explorer/src/main/resources/com/r3corda/explorer/images/search.png new file mode 100644 index 0000000000..0d56abaf0e Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/search.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/settings_lrg.png b/explorer/src/main/resources/com/r3corda/explorer/images/settings_lrg.png new file mode 100644 index 0000000000..c7703ddf15 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/settings_lrg.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/settings_w.png b/explorer/src/main/resources/com/r3corda/explorer/images/settings_w.png new file mode 100644 index 0000000000..e109ab8657 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/settings_w.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/tx.png b/explorer/src/main/resources/com/r3corda/explorer/images/tx.png new file mode 100644 index 0000000000..95dc001792 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/tx.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/tx_lrg.png b/explorer/src/main/resources/com/r3corda/explorer/images/tx_lrg.png new file mode 100644 index 0000000000..32bc4a894c Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/tx_lrg.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/user_b.png b/explorer/src/main/resources/com/r3corda/explorer/images/user_b.png new file mode 100644 index 0000000000..12ebf1d8aa Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/user_b.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/user_b.svg b/explorer/src/main/resources/com/r3corda/explorer/images/user_b.svg new file mode 100644 index 0000000000..7262168615 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/images/user_b.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/user_w.png b/explorer/src/main/resources/com/r3corda/explorer/images/user_w.png new file mode 100644 index 0000000000..b3e4c7bd12 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/user_w.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/images/woman1.png b/explorer/src/main/resources/com/r3corda/explorer/images/woman1.png new file mode 100644 index 0000000000..3f97396687 Binary files /dev/null and b/explorer/src/main/resources/com/r3corda/explorer/images/woman1.png differ diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/CashStateViewer.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/CashStateViewer.fxml new file mode 100644 index 0000000000..de9415f8a1 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/CashStateViewer.fxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/CashViewer.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/CashViewer.fxml new file mode 100644 index 0000000000..202420c876 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/CashViewer.fxml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/Home.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/Home.fxml new file mode 100644 index 0000000000..ea6657e8d4 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/Home.fxml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/TopLevel.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/TopLevel.fxml new file mode 100644 index 0000000000..7233afa8b0 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/TopLevel.fxml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/TransactionViewer.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/TransactionViewer.fxml new file mode 100644 index 0000000000..1b43458c58 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/TransactionViewer.fxml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/login.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/login.fxml new file mode 100644 index 0000000000..cda9c779e3 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/login.fxml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/schedule.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/schedule.fxml new file mode 100644 index 0000000000..6bcab11e41 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/schedule.fxml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/search.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/search.fxml new file mode 100644 index 0000000000..ee68a49b11 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/search.fxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/searchfield.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/searchfield.fxml new file mode 100644 index 0000000000..ee68a49b11 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/searchfield.fxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/settings.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/settings.fxml new file mode 100644 index 0000000000..51506030e5 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/settings.fxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/explorer/src/main/resources/com/r3corda/explorer/views/wallet-hover.fxml b/explorer/src/main/resources/com/r3corda/explorer/views/wallet-hover.fxml new file mode 100644 index 0000000000..5cf0dad257 --- /dev/null +++ b/explorer/src/main/resources/com/r3corda/explorer/views/wallet-hover.fxml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/node/src/main/kotlin/com/r3corda/node/services/monitor/Events.kt b/node/src/main/kotlin/com/r3corda/node/services/monitor/Events.kt index 4540bb398c..ff1fd8b2b3 100644 --- a/node/src/main/kotlin/com/r3corda/node/services/monitor/Events.kt +++ b/node/src/main/kotlin/com/r3corda/node/services/monitor/Events.kt @@ -1,7 +1,7 @@ package com.r3corda.node.services.monitor import com.r3corda.core.contracts.* -import com.r3corda.core.transactions.SignedTransaction +import com.r3corda.core.transactions.LedgerTransaction import com.r3corda.node.utilities.AddOrRemove import java.time.Instant import java.util.* @@ -10,8 +10,8 @@ import java.util.* * Events triggered by changes in the node, and sent to monitoring client(s). */ sealed class ServiceToClientEvent(val time: Instant) { - class Transaction(time: Instant, val transaction: SignedTransaction) : ServiceToClientEvent(time) { - override fun toString() = "Transaction(${transaction.tx.commands})" + class Transaction(time: Instant, val transaction: LedgerTransaction) : ServiceToClientEvent(time) { + override fun toString() = "Transaction(${transaction.commands})" } class OutputState( time: Instant, @@ -26,7 +26,7 @@ sealed class ServiceToClientEvent(val time: Instant) { val label: String, val addOrRemove: AddOrRemove ) : ServiceToClientEvent(time) { - override fun toString() = "StateMachine(${addOrRemove.name})" + override fun toString() = "StateMachine($label, ${addOrRemove.name})" } class Progress(time: Instant, val fiberId: Long, val message: String) : ServiceToClientEvent(time) { override fun toString() = "Progress($message)" @@ -46,7 +46,7 @@ sealed class TransactionBuildResult { * * @param transaction the transaction created as a result, in the case where the protocol has completed. */ - class ProtocolStarted(val fiberId: Long, val transaction: SignedTransaction?, val message: String?) : TransactionBuildResult() { + class ProtocolStarted(val fiberId: Long, val transaction: LedgerTransaction?, val message: String?) : TransactionBuildResult() { override fun toString() = "Started($message)" } diff --git a/node/src/main/kotlin/com/r3corda/node/services/monitor/Messages.kt b/node/src/main/kotlin/com/r3corda/node/services/monitor/Messages.kt index 798f319c8f..610770ecdb 100644 --- a/node/src/main/kotlin/com/r3corda/node/services/monitor/Messages.kt +++ b/node/src/main/kotlin/com/r3corda/node/services/monitor/Messages.kt @@ -2,6 +2,7 @@ package com.r3corda.node.services.monitor import com.r3corda.core.contracts.ClientToServiceCommand import com.r3corda.core.contracts.ContractState +import com.r3corda.core.contracts.StateAndRef import com.r3corda.core.messaging.SingleMessageRecipient import com.r3corda.protocols.DirectRequestMessage @@ -14,6 +15,6 @@ data class DeregisterRequest(override val replyToRecipient: SingleMessageRecipie override val sessionID: Long) : DirectRequestMessage data class DeregisterResponse(val success: Boolean) -data class StateSnapshotMessage(val contractStates: Collection, val protocolStates: Collection) +data class StateSnapshotMessage(val contractStates: Collection>, val protocolStates: Collection) data class ClientToServiceCommandMessage(override val sessionID: Long, override val replyToRecipient: SingleMessageRecipient, val command: ClientToServiceCommand) : DirectRequestMessage diff --git a/node/src/main/kotlin/com/r3corda/node/services/monitor/WalletMonitorService.kt b/node/src/main/kotlin/com/r3corda/node/services/monitor/WalletMonitorService.kt index c6fab68f50..6f4d3cd7a7 100644 --- a/node/src/main/kotlin/com/r3corda/node/services/monitor/WalletMonitorService.kt +++ b/node/src/main/kotlin/com/r3corda/node/services/monitor/WalletMonitorService.kt @@ -11,7 +11,7 @@ import com.r3corda.core.node.services.DEFAULT_SESSION_ID import com.r3corda.core.node.services.Wallet import com.r3corda.core.protocols.ProtocolLogic import com.r3corda.core.serialization.serialize -import com.r3corda.core.transactions.SignedTransaction +import com.r3corda.core.transactions.LedgerTransaction import com.r3corda.core.transactions.TransactionBuilder import com.r3corda.core.utilities.loggerFor import com.r3corda.node.services.api.AbstractNodeService @@ -59,7 +59,7 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa addMessageHandler(OUT_EVENT_TOPIC) { req: ClientToServiceCommandMessage -> processEventRequest(req) } // Notify listeners on state changes - services.storageService.validatedTransactions.updates.subscribe { tx -> notifyTransaction(tx) } + services.storageService.validatedTransactions.updates.subscribe { tx -> notifyTransaction(tx.tx.toLedgerTransaction(services)) } services.walletService.updates.subscribe { update -> notifyWalletUpdate(update) } smm.changes.subscribe { change -> val fiberId: Long = change.third @@ -85,7 +85,7 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa = notifyEvent(ServiceToClientEvent.OutputState(Instant.now(), update.consumed, update.produced)) @VisibleForTesting - internal fun notifyTransaction(transaction: SignedTransaction) + internal fun notifyTransaction(transaction: LedgerTransaction) = notifyEvent(ServiceToClientEvent.Transaction(Instant.now(), transaction)) private fun processEventRequest(reqMessage: ClientToServiceCommandMessage) { @@ -94,11 +94,12 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa try { when (req) { is ClientToServiceCommand.IssueCash -> issueCash(req) - is ClientToServiceCommand.PayCash -> initatePayment(req) + is ClientToServiceCommand.PayCash -> initiatePayment(req) is ClientToServiceCommand.ExitCash -> exitCash(req) else -> throw IllegalArgumentException("Unknown request type ${req.javaClass.name}") } } catch(ex: Exception) { + logger.warn("Exception while processing message of type ${req.javaClass.simpleName}", ex) TransactionBuildResult.Failed(ex.message) } @@ -134,7 +135,7 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa fun processRegisterRequest(req: RegisterRequest) { try { listeners.add(RegisteredListener(req.replyToRecipient, req.sessionID)) - val stateMessage = StateSnapshotMessage(services.walletService.currentWallet.states.map { it.state.data }.toList(), + val stateMessage = StateSnapshotMessage(services.walletService.currentWallet.states.toList(), smm.allStateMachines.map { it.javaClass.name }) net.send(net.createMessage(STATE_TOPIC, DEFAULT_SESSION_ID, stateMessage.serialize().bits), req.replyToRecipient) @@ -151,7 +152,7 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa } // TODO: Make a lightweight protocol that manages this workflow, rather than embedding it directly in the service - private fun initatePayment(req: ClientToServiceCommand.PayCash): TransactionBuildResult { + private fun initiatePayment(req: ClientToServiceCommand.PayCash): TransactionBuildResult { val builder: TransactionBuilder = TransactionType.General.Builder(null) // TODO: Have some way of restricting this to states the caller controls try { @@ -165,7 +166,11 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa } val tx = builder.toSignedTransaction(checkSufficientSignatures = false) val protocol = FinalityProtocol(tx, setOf(req), setOf(req.recipient)) - return TransactionBuildResult.ProtocolStarted(smm.add(BroadcastTransactionProtocol.TOPIC, protocol).machineId, tx, "Cash payment transaction generated") + return TransactionBuildResult.ProtocolStarted( + smm.add(BroadcastTransactionProtocol.TOPIC, protocol).machineId, + tx.tx.toLedgerTransaction(services), + "Cash payment transaction generated" + ) } catch(ex: InsufficientBalanceException) { return TransactionBuildResult.Failed(ex.message ?: "Insufficient balance") } @@ -174,27 +179,35 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa // TODO: Make a lightweight protocol that manages this workflow, rather than embedding it directly in the service private fun exitCash(req: ClientToServiceCommand.ExitCash): TransactionBuildResult { val builder: TransactionBuilder = TransactionType.General.Builder(null) - val issuer = PartyAndReference(services.storageService.myLegalIdentity, req.issueRef) - Cash().generateExit(builder, req.amount.issuedBy(issuer), - services.walletService.currentWallet.statesOfType().filter { it.state.data.owner == issuer.party.owningKey }) - builder.signWith(services.storageService.myLegalIdentityKey) + try { + val issuer = PartyAndReference(services.storageService.myLegalIdentity, req.issueRef) + Cash().generateExit(builder, req.amount.issuedBy(issuer), + services.walletService.currentWallet.statesOfType().filter { it.state.data.owner == issuer.party.owningKey }) + builder.signWith(services.storageService.myLegalIdentityKey) - // Work out who the owners of the burnt states were - val inputStatesNullable = services.walletService.statesForRefs(builder.inputStates()) - val inputStates = inputStatesNullable.values.filterNotNull().map { it.data } - if (inputStatesNullable.size != inputStates.size) { - val unresolvedStateRefs = inputStatesNullable.filter { it.value == null }.map { it.key } - throw InputStateRefResolveFailed(unresolvedStateRefs) + // Work out who the owners of the burnt states were + val inputStatesNullable = services.walletService.statesForRefs(builder.inputStates()) + val inputStates = inputStatesNullable.values.filterNotNull().map { it.data } + if (inputStatesNullable.size != inputStates.size) { + val unresolvedStateRefs = inputStatesNullable.filter { it.value == null }.map { it.key } + throw InputStateRefResolveFailed(unresolvedStateRefs) + } + + // TODO: Is it safe to drop participants we don't know how to contact? Does not knowing how to contact them + // count as a reason to fail? + val participants: Set = inputStates.filterIsInstance().map { services.identityService.partyFromKey(it.owner) }.filterNotNull().toSet() + + // Commit the transaction + val tx = builder.toSignedTransaction(checkSufficientSignatures = false) + val protocol = FinalityProtocol(tx, setOf(req), participants) + return TransactionBuildResult.ProtocolStarted( + smm.add(BroadcastTransactionProtocol.TOPIC, protocol).machineId, + tx.tx.toLedgerTransaction(services), + "Cash destruction transaction generated" + ) + } catch (ex: InsufficientBalanceException) { + return TransactionBuildResult.Failed(ex.message ?: "Insufficient balance") } - - // TODO: Is it safe to drop participants we don't know how to contact? Does not knowing how to contact them - // count as a reason to fail? - val participants: Set = inputStates.filterIsInstance().map { services.identityService.partyFromKey(it.owner) }.filterNotNull().toSet() - - // Commit the transaction - val tx = builder.toSignedTransaction(checkSufficientSignatures = false) - val protocol = FinalityProtocol(tx, setOf(req), participants) - return TransactionBuildResult.ProtocolStarted(smm.add(BroadcastTransactionProtocol.TOPIC, protocol).machineId, tx, "Cash destruction transaction generated") } // TODO: Make a lightweight protocol that manages this workflow, rather than embedding it directly in the service @@ -206,7 +219,11 @@ class WalletMonitorService(services: ServiceHubInternal, val smm: StateMachineMa val tx = builder.toSignedTransaction(checkSufficientSignatures = true) // Issuance transactions do not need to be notarised, so we can skip directly to broadcasting it val protocol = BroadcastTransactionProtocol(tx, setOf(req), setOf(req.recipient)) - return TransactionBuildResult.ProtocolStarted(smm.add(BroadcastTransactionProtocol.TOPIC, protocol).machineId, tx, "Cash issuance completed") + return TransactionBuildResult.ProtocolStarted( + smm.add(BroadcastTransactionProtocol.TOPIC, protocol).machineId, + tx.tx.toLedgerTransaction(services), + "Cash issuance completed" + ) } class InputStateRefResolveFailed(stateRefs: List) : diff --git a/node/src/test/kotlin/com/r3corda/node/services/WalletMonitorServiceTests.kt b/node/src/test/kotlin/com/r3corda/node/services/WalletMonitorServiceTests.kt index db638ee4a7..c7f0199add 100644 --- a/node/src/test/kotlin/com/r3corda/node/services/WalletMonitorServiceTests.kt +++ b/node/src/test/kotlin/com/r3corda/node/services/WalletMonitorServiceTests.kt @@ -140,7 +140,7 @@ class WalletMonitorServiceTests { // Check the returned event is correct val tx = (event.state as TransactionBuildResult.ProtocolStarted).transaction assertNotNull(tx) - assertEquals(expectedState, tx!!.tx.outputs.single().data) + assertEquals(expectedState, tx!!.outputs.single().data) }, expect { event: ServiceToClientEvent.OutputState -> // Check the generated state is correct @@ -202,8 +202,8 @@ class WalletMonitorServiceTests { } ), expect { event: ServiceToClientEvent.Transaction -> - require(event.transaction.sigs.size == 1) - event.transaction.sigs.map { it.by }.toSet().containsAll( + require(event.transaction.mustSign.size == 1) + event.transaction.mustSign.containsAll( listOf( monitorServiceNode.services.storageService.myLegalIdentity.owningKey ) diff --git a/settings.gradle b/settings.gradle index 5e623eeae2..5eb65bc579 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,3 +7,4 @@ include 'client' include 'experimental' include 'test-utils' include 'network-simulator' +include 'explorer'