mirror of
https://github.com/corda/corda.git
synced 2025-02-06 02:59:15 +00:00
Merge pull request #1 from corda/kstreich-explorer-flows
Flow view from explorer
This commit is contained in:
commit
d0daac8dd6
15
.idea/runConfigurations/Explorer___demo_nodes__flow_triage_.xml
generated
Normal file
15
.idea/runConfigurations/Explorer___demo_nodes__flow_triage_.xml
generated
Normal file
@ -0,0 +1,15 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Explorer - demo nodes (flow triage)" type="JetRunConfigurationType" factoryName="Kotlin" singleton="true">
|
||||
<extension name="coverage" enabled="false" merge="false" sample_coverage="true" runner="idea" />
|
||||
<option name="MAIN_CLASS_NAME" value="net.corda.explorer.MainKt" />
|
||||
<option name="VM_PARAMETERS" value="-DAMQ_DELIVERY_DELAY_MS=15000" />
|
||||
<option name="PROGRAM_PARAMETERS" value="-F" />
|
||||
<option name="WORKING_DIRECTORY" value="" />
|
||||
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||
<option name="ALTERNATIVE_JRE_PATH" value="1.8" />
|
||||
<option name="PASS_PARENT_ENVS" value="true" />
|
||||
<module name="explorer_main" />
|
||||
<envs />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
@ -4,9 +4,7 @@ import com.google.common.net.HostAndPort
|
||||
import javafx.beans.property.SimpleObjectProperty
|
||||
import net.corda.client.rpc.CordaRPCClient
|
||||
import net.corda.client.rpc.CordaRPCClientConfiguration
|
||||
import net.corda.core.flows.StateMachineRunId
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.messaging.StateMachineInfo
|
||||
import net.corda.core.messaging.StateMachineUpdate
|
||||
import net.corda.core.node.services.NetworkMapCache.MapChange
|
||||
import net.corda.core.node.services.StateMachineTransactionMapping
|
||||
@ -16,17 +14,6 @@ import net.corda.core.transactions.SignedTransaction
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
|
||||
data class ProgressTrackingEvent(val stateMachineId: StateMachineRunId, val message: String) {
|
||||
companion object {
|
||||
fun createStreamFromStateMachineInfo(stateMachine: StateMachineInfo): Observable<ProgressTrackingEvent>? {
|
||||
return stateMachine.progressTrackerStepAndUpdates?.let { pair ->
|
||||
val (current, future) = pair
|
||||
future.map { ProgressTrackingEvent(stateMachine.id, it) }.startWith(ProgressTrackingEvent(stateMachine.id, current))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This model exposes raw event streams to and from the node.
|
||||
*/
|
||||
@ -75,7 +62,9 @@ class NodeMonitorModel {
|
||||
Observable.empty<ProgressTrackingEvent>()
|
||||
}
|
||||
}
|
||||
futureProgressTrackerUpdates.startWith(currentProgressTrackerUpdates).flatMap { it }.subscribe(progressTrackingSubject)
|
||||
|
||||
// We need to retry, because when flow errors, we unsubscribe from progressTrackingSubject. So we end up with stream of state machine updates and no progress trackers.
|
||||
futureProgressTrackerUpdates.startWith(currentProgressTrackerUpdates).flatMap { it }.retry().subscribe(progressTrackingSubject)
|
||||
|
||||
// Now the state machines
|
||||
val currentStateMachines = stateMachines.map { StateMachineUpdate.Added(it) }
|
||||
|
@ -0,0 +1,97 @@
|
||||
package net.corda.client.jfx.model
|
||||
|
||||
import javafx.beans.property.SimpleIntegerProperty
|
||||
import javafx.beans.property.SimpleObjectProperty
|
||||
import javafx.beans.value.ObservableValue
|
||||
import javafx.collections.FXCollections
|
||||
import net.corda.client.jfx.utils.fold
|
||||
import net.corda.client.jfx.utils.map
|
||||
import net.corda.client.jfx.utils.recordAsAssociation
|
||||
import net.corda.core.ErrorOr
|
||||
import net.corda.core.flows.FlowInitiator
|
||||
import net.corda.core.flows.StateMachineRunId
|
||||
import net.corda.core.messaging.StateMachineInfo
|
||||
import net.corda.core.messaging.StateMachineUpdate
|
||||
import org.fxmisc.easybind.EasyBind
|
||||
import rx.Observable
|
||||
|
||||
data class ProgressTrackingEvent(val stateMachineId: StateMachineRunId, val message: String) {
|
||||
companion object {
|
||||
fun createStreamFromStateMachineInfo(stateMachine: StateMachineInfo): Observable<ProgressTrackingEvent>? {
|
||||
return stateMachine.progressTrackerStepAndUpdates?.let { (current, future) ->
|
||||
future.map { ProgressTrackingEvent(stateMachine.id, it) }.startWith(ProgressTrackingEvent(stateMachine.id, current))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ProgressStatus(val status: String?)
|
||||
|
||||
sealed class StateMachineStatus {
|
||||
data class Added(val id: StateMachineRunId, val stateMachineName: String, val flowInitiator: FlowInitiator) : StateMachineStatus()
|
||||
data class Removed(val id: StateMachineRunId, val result: ErrorOr<*>) : StateMachineStatus()
|
||||
}
|
||||
|
||||
data class StateMachineData(
|
||||
val id: StateMachineRunId,
|
||||
val stateMachineName: String,
|
||||
val flowInitiator: FlowInitiator,
|
||||
val smmStatus: Pair<ObservableValue<StateMachineStatus>, ObservableValue<ProgressStatus>>
|
||||
)
|
||||
|
||||
data class Counter(
|
||||
var errored: SimpleIntegerProperty = SimpleIntegerProperty(0),
|
||||
var success: SimpleIntegerProperty = SimpleIntegerProperty(0),
|
||||
var progress: SimpleIntegerProperty = SimpleIntegerProperty(0)
|
||||
) {
|
||||
fun addSmm() { progress.value += 1 }
|
||||
fun removeSmm(result: ErrorOr<*>) {
|
||||
progress.value -= 1
|
||||
when (result.error) {
|
||||
null -> success.value += 1
|
||||
else -> errored.value += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StateMachineDataModel {
|
||||
private val stateMachineUpdates by observable(NodeMonitorModel::stateMachineUpdates)
|
||||
private val progressTracking by observable(NodeMonitorModel::progressTracking)
|
||||
private val progressEvents = progressTracking.recordAsAssociation(ProgressTrackingEvent::stateMachineId)
|
||||
|
||||
val counter = Counter()
|
||||
|
||||
private val stateMachineIndexMap = HashMap<StateMachineRunId, Int>()
|
||||
private val stateMachineStatus = stateMachineUpdates.fold(FXCollections.observableArrayList<SimpleObjectProperty<StateMachineStatus>>()) { list, update ->
|
||||
when (update) {
|
||||
is StateMachineUpdate.Added -> {
|
||||
counter.addSmm()
|
||||
val flowInitiator= update.stateMachineInfo.initiator
|
||||
val added: SimpleObjectProperty<StateMachineStatus> =
|
||||
SimpleObjectProperty(StateMachineStatus.Added(update.id, update.stateMachineInfo.flowLogicClassName, flowInitiator))
|
||||
list.add(added)
|
||||
stateMachineIndexMap[update.id] = list.size - 1
|
||||
}
|
||||
is StateMachineUpdate.Removed -> {
|
||||
val addedIdx = stateMachineIndexMap[update.id]
|
||||
val added = addedIdx?.let { list.getOrNull(addedIdx) }
|
||||
added ?: throw Exception("State machine removed with unknown id ${update.id}")
|
||||
counter.removeSmm(update.result)
|
||||
list[addedIdx].set(StateMachineStatus.Removed(update.id, update.result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val stateMachineDataList = stateMachineStatus.map {
|
||||
val smStatus = it.value as StateMachineStatus.Added
|
||||
val id = smStatus.id
|
||||
val progress = SimpleObjectProperty(progressEvents.get(id))
|
||||
StateMachineData(id, smStatus.stateMachineName, smStatus.flowInitiator,
|
||||
Pair(it, EasyBind.map(progress) { ProgressStatus(it?.message) }))
|
||||
}
|
||||
|
||||
val stateMachinesAll = stateMachineDataList
|
||||
val error = counter.errored
|
||||
val success = counter.success
|
||||
val progress = counter.progress
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
package net.corda.client.jfx.model
|
||||
|
||||
import javafx.beans.property.SimpleObjectProperty
|
||||
import javafx.beans.value.ObservableValue
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.collections.ObservableMap
|
||||
import net.corda.client.jfx.utils.*
|
||||
@ -10,8 +8,6 @@ import net.corda.core.contracts.ContractState
|
||||
import net.corda.core.contracts.StateAndRef
|
||||
import net.corda.core.contracts.StateRef
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.flows.StateMachineRunId
|
||||
import net.corda.core.messaging.StateMachineUpdate
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import org.fxmisc.easybind.EasyBind
|
||||
|
||||
@ -58,53 +54,15 @@ data class PartiallyResolvedTransaction(
|
||||
}
|
||||
}
|
||||
|
||||
data class FlowStatus(val status: String)
|
||||
|
||||
sealed class StateMachineStatus {
|
||||
abstract val stateMachineName: String
|
||||
|
||||
data class Added(override val stateMachineName: String) : StateMachineStatus()
|
||||
data class Removed(override val stateMachineName: String) : StateMachineStatus()
|
||||
}
|
||||
|
||||
data class StateMachineData(
|
||||
val id: StateMachineRunId,
|
||||
val flowStatus: ObservableValue<FlowStatus?>,
|
||||
val stateMachineStatus: ObservableValue<StateMachineStatus>
|
||||
)
|
||||
|
||||
/**
|
||||
* This model provides an observable list of transactions and what state machines/flows recorded them
|
||||
*/
|
||||
class TransactionDataModel {
|
||||
private val transactions by observable(NodeMonitorModel::transactions)
|
||||
private val stateMachineUpdates by observable(NodeMonitorModel::stateMachineUpdates)
|
||||
private val progressTracking by observable(NodeMonitorModel::progressTracking)
|
||||
private val stateMachineTransactionMapping by observable(NodeMonitorModel::stateMachineTransactionMapping)
|
||||
|
||||
private val collectedTransactions = transactions.recordInSequence()
|
||||
private val transactionMap = collectedTransactions.associateBy(SignedTransaction::id)
|
||||
private val progressEvents = progressTracking.recordAsAssociation(ProgressTrackingEvent::stateMachineId)
|
||||
private val stateMachineStatus = stateMachineUpdates.fold(FXCollections.observableHashMap<StateMachineRunId, SimpleObjectProperty<StateMachineStatus>>()) { map, update ->
|
||||
when (update) {
|
||||
is StateMachineUpdate.Added -> {
|
||||
val added: SimpleObjectProperty<StateMachineStatus> =
|
||||
SimpleObjectProperty(StateMachineStatus.Added(update.stateMachineInfo.flowLogicClassName))
|
||||
map[update.id] = added
|
||||
}
|
||||
is StateMachineUpdate.Removed -> {
|
||||
val added = map[update.id]
|
||||
added ?: throw Exception("State machine removed with unknown id ${update.id}")
|
||||
added.set(StateMachineStatus.Removed(added.value.stateMachineName))
|
||||
}
|
||||
}
|
||||
}
|
||||
private val stateMachineDataList = LeftOuterJoinedMap(stateMachineStatus, progressEvents) { id, status, progress ->
|
||||
StateMachineData(id, progress.map { it?.let { FlowStatus(it.message) } }, status)
|
||||
}.getObservableValues()
|
||||
// TODO : Create a new screen for state machines.
|
||||
private val stateMachineDataMap = stateMachineDataList.associateBy(StateMachineData::id)
|
||||
private val smTxMappingList = stateMachineTransactionMapping.recordInSequence()
|
||||
|
||||
val partiallyResolvedTransactions = collectedTransactions.map {
|
||||
PartiallyResolvedTransaction.fromSignedTransaction(it, transactionMap)
|
||||
}
|
||||
|
@ -1,36 +1,90 @@
|
||||
package net.corda.client.mock
|
||||
|
||||
import net.corda.core.contracts.Amount
|
||||
import net.corda.core.contracts.GBP
|
||||
import net.corda.core.contracts.USD
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.serialization.OpaqueBytes
|
||||
import net.corda.flows.CashFlowCommand
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* [Generator]s for incoming/outgoing events to/from the [WalletMonitorService]. Internally it keeps track of owned
|
||||
* state/ref pairs, but it doesn't necessarily generate "correct" events!
|
||||
* [Generator]s for incoming/outgoing cash flow events between parties. It doesn't necessarily generate correct events!
|
||||
* Especially at the beginning of simulation there might be few insufficient spend errors.
|
||||
*/
|
||||
|
||||
class EventGenerator(val parties: List<Party>, val currencies: List<Currency>, val notary: Party) {
|
||||
private val partyGenerator = Generator.pickOne(parties)
|
||||
private val issueRefGenerator = Generator.intRange(0, 1).map { number -> OpaqueBytes(ByteArray(1, { number.toByte() })) }
|
||||
private val amountGenerator = Generator.longRange(10000, 1000000)
|
||||
private val currencyGenerator = Generator.pickOne(currencies)
|
||||
open class EventGenerator(val parties: List<Party>, val currencies: List<Currency>, val notary: Party) {
|
||||
protected val partyGenerator = Generator.pickOne(parties)
|
||||
protected val issueRefGenerator = Generator.intRange(0, 1).map { number -> OpaqueBytes(ByteArray(1, { number.toByte() })) }
|
||||
protected val amountGenerator = Generator.longRange(10000, 1000000)
|
||||
protected val currencyGenerator = Generator.pickOne(currencies)
|
||||
protected val currencyMap: MutableMap<Currency, Long> = mutableMapOf(USD to 0L, GBP to 0L) // Used for estimation of how much money we have in general.
|
||||
|
||||
private val issueCashGenerator = amountGenerator.combine(partyGenerator, issueRefGenerator, currencyGenerator) { amount, to, issueRef, ccy ->
|
||||
protected fun addToMap(ccy: Currency, amount: Long) {
|
||||
currencyMap.computeIfPresent(ccy) { _, value -> Math.max(0L, value + amount) }
|
||||
}
|
||||
|
||||
protected val issueCashGenerator = amountGenerator.combine(partyGenerator, issueRefGenerator, currencyGenerator) { amount, to, issueRef, ccy ->
|
||||
addToMap(ccy, amount)
|
||||
CashFlowCommand.IssueCash(Amount(amount, ccy), issueRef, to, notary)
|
||||
}
|
||||
|
||||
private val exitCashGenerator = amountGenerator.combine(issueRefGenerator, currencyGenerator) { amount, issueRef, ccy ->
|
||||
protected val exitCashGenerator = amountGenerator.combine(issueRefGenerator, currencyGenerator) { amount, issueRef, ccy ->
|
||||
addToMap(ccy, -amount)
|
||||
CashFlowCommand.ExitCash(Amount(amount, ccy), issueRef)
|
||||
}
|
||||
|
||||
val moveCashGenerator = amountGenerator.combine(partyGenerator, currencyGenerator) { amountIssued, recipient, currency ->
|
||||
open val moveCashGenerator = amountGenerator.combine(partyGenerator, currencyGenerator) { amountIssued, recipient, currency ->
|
||||
CashFlowCommand.PayCash(Amount(amountIssued, currency), recipient)
|
||||
}
|
||||
|
||||
val issuerGenerator = Generator.frequency(listOf(
|
||||
open val issuerGenerator = Generator.frequency(listOf(
|
||||
0.1 to exitCashGenerator,
|
||||
0.9 to issueCashGenerator
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* [Generator]s for incoming/outgoing events of starting different cash flows. It invokes flows that throw exceptions
|
||||
* for use in explorer flow triage. Exceptions are of kind spending/exiting too much cash.
|
||||
*/
|
||||
class ErrorFlowsEventGenerator(parties: List<Party>, currencies: List<Currency>, notary: Party): EventGenerator(parties, currencies, notary) {
|
||||
enum class IssuerEvents {
|
||||
NORMAL_EXIT,
|
||||
EXIT_ERROR
|
||||
}
|
||||
|
||||
val errorGenerator = Generator.pickOne(IssuerEvents.values().toList())
|
||||
|
||||
val errorExitCashGenerator = amountGenerator.combine(issueRefGenerator, currencyGenerator, errorGenerator) { amount, issueRef, ccy, errorType ->
|
||||
when (errorType) {
|
||||
IssuerEvents.NORMAL_EXIT -> {
|
||||
println("Normal exit")
|
||||
if (currencyMap[ccy]!! <= amount) addToMap(ccy, -amount)
|
||||
CashFlowCommand.ExitCash(Amount(amount, ccy), issueRef) // It may fail at the beginning, but we don't care.
|
||||
}
|
||||
IssuerEvents.EXIT_ERROR -> {
|
||||
println("Exit error")
|
||||
CashFlowCommand.ExitCash(Amount(currencyMap[ccy]!! * 2, ccy), issueRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val normalMoveGenerator = amountGenerator.combine(partyGenerator, currencyGenerator) { amountIssued, recipient, currency ->
|
||||
CashFlowCommand.PayCash(Amount(amountIssued, currency), recipient)
|
||||
}
|
||||
|
||||
val errorMoveGenerator = partyGenerator.combine(currencyGenerator) { recipient, currency ->
|
||||
CashFlowCommand.PayCash(Amount(currencyMap[currency]!! * 2, currency), recipient)
|
||||
}
|
||||
|
||||
override val moveCashGenerator = Generator.frequency(listOf(
|
||||
0.2 to errorMoveGenerator,
|
||||
0.8 to normalMoveGenerator
|
||||
))
|
||||
|
||||
override val issuerGenerator = Generator.frequency(listOf(
|
||||
0.3 to errorExitCashGenerator,
|
||||
0.7 to issueCashGenerator
|
||||
))
|
||||
}
|
||||
|
@ -217,7 +217,7 @@ abstract class FlowLogic<out T> {
|
||||
fun track(): Pair<String, Observable<String>>? {
|
||||
// TODO this is not threadsafe, needs an atomic get-step-and-subscribe
|
||||
return progressTracker?.let {
|
||||
it.currentStep.toString() to it.changes.map { it.toString() }
|
||||
it.currentStep.label to it.changes.map { it.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,3 +68,9 @@ task(runSimulationNodes, dependsOn: 'classes', type: JavaExec) {
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
args '-S'
|
||||
}
|
||||
|
||||
task(runFlowTriageNodes, dependsOn: 'classes', type: JavaExec) {
|
||||
main = 'net.corda.explorer.MainKt'
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
args '-F'
|
||||
}
|
||||
|
@ -0,0 +1,206 @@
|
||||
package net.corda.explorer
|
||||
|
||||
import joptsimple.OptionSet
|
||||
import net.corda.client.mock.ErrorFlowsEventGenerator
|
||||
import net.corda.client.mock.EventGenerator
|
||||
import net.corda.client.mock.Generator
|
||||
import net.corda.client.mock.pickOne
|
||||
import net.corda.client.rpc.CordaRPCConnection
|
||||
import net.corda.contracts.asset.Cash
|
||||
import net.corda.core.contracts.Amount
|
||||
import net.corda.core.contracts.GBP
|
||||
import net.corda.core.contracts.USD
|
||||
import net.corda.core.failure
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.messaging.FlowHandle
|
||||
import net.corda.core.node.services.ServiceInfo
|
||||
import net.corda.core.node.services.ServiceType
|
||||
import net.corda.core.serialization.OpaqueBytes
|
||||
import net.corda.core.success
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.core.utilities.ALICE
|
||||
import net.corda.core.utilities.BOB
|
||||
import net.corda.core.utilities.DUMMY_NOTARY
|
||||
import net.corda.flows.CashExitFlow
|
||||
import net.corda.flows.CashFlowCommand
|
||||
import net.corda.flows.CashIssueFlow
|
||||
import net.corda.flows.CashPaymentFlow
|
||||
import net.corda.flows.IssuerFlow
|
||||
import net.corda.node.driver.NodeHandle
|
||||
import net.corda.node.driver.PortAllocation
|
||||
import net.corda.node.driver.driver
|
||||
import net.corda.node.services.startFlowPermission
|
||||
import net.corda.node.services.transactions.SimpleNotaryService
|
||||
import net.corda.nodeapi.User
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class ExplorerSimulation(val options: OptionSet) {
|
||||
val user = User("user1", "test", permissions = setOf(
|
||||
startFlowPermission<CashPaymentFlow>()
|
||||
))
|
||||
val manager = User("manager", "test", permissions = setOf(
|
||||
startFlowPermission<CashIssueFlow>(),
|
||||
startFlowPermission<CashPaymentFlow>(),
|
||||
startFlowPermission<CashExitFlow>(),
|
||||
startFlowPermission<IssuerFlow.IssuanceRequester>())
|
||||
)
|
||||
|
||||
lateinit var notaryNode: NodeHandle
|
||||
lateinit var aliceNode: NodeHandle
|
||||
lateinit var bobNode: NodeHandle
|
||||
lateinit var issuerNodeGBP: NodeHandle
|
||||
lateinit var issuerNodeUSD: NodeHandle
|
||||
|
||||
val RPCConnections = ArrayList<CordaRPCConnection>()
|
||||
val issuers = HashMap<Currency, CordaRPCOps>()
|
||||
val parties = ArrayList<Pair<Party, CordaRPCOps>>()
|
||||
|
||||
init {
|
||||
startDemoNodes()
|
||||
}
|
||||
|
||||
private fun onEnd() {
|
||||
println("Closing RPC connections")
|
||||
RPCConnections.forEach { it.close() }
|
||||
}
|
||||
|
||||
private fun startDemoNodes() {
|
||||
val portAllocation = PortAllocation.Incremental(20000)
|
||||
driver(portAllocation = portAllocation) {
|
||||
// TODO : Supported flow should be exposed somehow from the node instead of set of ServiceInfo.
|
||||
val notary = startNode(DUMMY_NOTARY.name, advertisedServices = setOf(ServiceInfo(SimpleNotaryService.type)),
|
||||
customOverrides = mapOf("nearestCity" to "Zurich"))
|
||||
val alice = startNode(ALICE.name, rpcUsers = arrayListOf(user),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))),
|
||||
customOverrides = mapOf("nearestCity" to "Milan"))
|
||||
val bob = startNode(BOB.name, rpcUsers = arrayListOf(user),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))),
|
||||
customOverrides = mapOf("nearestCity" to "Madrid"))
|
||||
val ukBankName = X500Name("CN=UK Bank Plc,O=UK Bank Plc,L=London,C=UK")
|
||||
val usaBankName = X500Name("CN=USA Bank Corp,O=USA Bank Corp,L=New York,C=USA")
|
||||
val issuerGBP = startNode(ukBankName, rpcUsers = arrayListOf(manager),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("issuer.GBP"))),
|
||||
customOverrides = mapOf("nearestCity" to "London"))
|
||||
val issuerUSD = startNode(usaBankName, rpcUsers = arrayListOf(manager),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("issuer.USD"))),
|
||||
customOverrides = mapOf("nearestCity" to "New York"))
|
||||
|
||||
notaryNode = notary.get()
|
||||
aliceNode = alice.get()
|
||||
bobNode = bob.get()
|
||||
issuerNodeGBP = issuerGBP.get()
|
||||
issuerNodeUSD = issuerUSD.get()
|
||||
|
||||
arrayOf(notaryNode, aliceNode, bobNode, issuerNodeGBP, issuerNodeUSD).forEach {
|
||||
println("${it.nodeInfo.legalIdentity} started on ${it.configuration.rpcAddress}")
|
||||
}
|
||||
|
||||
when {
|
||||
options.has("S") -> startNormalSimulation()
|
||||
options.has("F") -> startErrorFlowsSimulation()
|
||||
}
|
||||
|
||||
waitForAllNodesToFinish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpRPC() {
|
||||
// Register with alice to use alice's RPC proxy to create random events.
|
||||
val aliceClient = aliceNode.rpcClientToNode()
|
||||
val aliceConnection = aliceClient.start(user.username, user.password)
|
||||
val aliceRPC = aliceConnection.proxy
|
||||
|
||||
val bobClient = bobNode.rpcClientToNode()
|
||||
val bobConnection = bobClient.start(user.username, user.password)
|
||||
val bobRPC = bobConnection.proxy
|
||||
|
||||
val issuerClientGBP = issuerNodeGBP.rpcClientToNode()
|
||||
val issuerGBPConnection = issuerClientGBP.start(manager.username, manager.password)
|
||||
val issuerRPCGBP = issuerGBPConnection.proxy
|
||||
|
||||
val issuerClientUSD = issuerNodeUSD.rpcClientToNode()
|
||||
val issuerUSDConnection =issuerClientUSD.start(manager.username, manager.password)
|
||||
val issuerRPCUSD = issuerUSDConnection.proxy
|
||||
|
||||
RPCConnections.addAll(listOf(aliceConnection, bobConnection, issuerGBPConnection, issuerUSDConnection))
|
||||
issuers.putAll(mapOf(USD to issuerRPCUSD, GBP to issuerRPCGBP))
|
||||
|
||||
parties.addAll(listOf(aliceNode.nodeInfo.legalIdentity to aliceRPC,
|
||||
bobNode.nodeInfo.legalIdentity to bobRPC,
|
||||
issuerNodeGBP.nodeInfo.legalIdentity to issuerRPCGBP,
|
||||
issuerNodeUSD.nodeInfo.legalIdentity to issuerRPCUSD))
|
||||
}
|
||||
|
||||
private fun startSimulation(eventGenerator: EventGenerator, maxIterations: Int) {
|
||||
// Log to logger when flow finish.
|
||||
fun FlowHandle<SignedTransaction>.log(seq: Int, name: String) {
|
||||
val out = "[$seq] $name $id :"
|
||||
returnValue.success {
|
||||
Main.log.info("$out ${it.id} ${(it.tx.outputs.first().data as Cash.State).amount}")
|
||||
}.failure {
|
||||
Main.log.info("$out ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0..maxIterations) {
|
||||
Thread.sleep(300)
|
||||
// Issuer requests.
|
||||
eventGenerator.issuerGenerator.map { command ->
|
||||
when (command) {
|
||||
is CashFlowCommand.IssueCash -> issuers[command.amount.token]?.let {
|
||||
println("${Instant.now()} [$i] ISSUING ${command.amount} with ref ${command.issueRef} to ${command.recipient}")
|
||||
command.startFlow(it).log(i, "${command.amount.token}Issuer")
|
||||
}
|
||||
is CashFlowCommand.ExitCash -> issuers[command.amount.token]?.let {
|
||||
println("${Instant.now()} [$i] EXITING ${command.amount} with ref ${command.issueRef}")
|
||||
command.startFlow(it).log(i, "${command.amount.token}Exit")
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unsupported command: $command")
|
||||
}
|
||||
}.generate(SplittableRandom())
|
||||
// Party pay requests.
|
||||
eventGenerator.moveCashGenerator.combine(Generator.pickOne(parties)) { command, (party, rpc) ->
|
||||
println("${Instant.now()} [$i] SENDING ${command.amount} from $party to ${command.recipient}")
|
||||
command.startFlow(rpc).log(i, party.name.toString())
|
||||
}.generate(SplittableRandom())
|
||||
}
|
||||
println("Simulation completed")
|
||||
}
|
||||
|
||||
private fun startNormalSimulation() {
|
||||
println("Running simulation mode ...")
|
||||
setUpRPC()
|
||||
val eventGenerator = EventGenerator(
|
||||
parties = parties.map { it.first },
|
||||
notary = notaryNode.nodeInfo.notaryIdentity,
|
||||
currencies = listOf(GBP, USD)
|
||||
)
|
||||
val maxIterations = 100_000
|
||||
// Pre allocate some money to each party.
|
||||
eventGenerator.parties.forEach {
|
||||
for (ref in 0..1) {
|
||||
for ((currency, issuer) in issuers) {
|
||||
CashFlowCommand.IssueCash(Amount(1_000_000, currency), OpaqueBytes(ByteArray(1, { ref.toByte() })), it, notaryNode.nodeInfo.notaryIdentity).startFlow(issuer)
|
||||
}
|
||||
}
|
||||
}
|
||||
startSimulation(eventGenerator, maxIterations)
|
||||
onEnd()
|
||||
}
|
||||
|
||||
private fun startErrorFlowsSimulation() {
|
||||
println("Running flows with errors simulation mode ...")
|
||||
setUpRPC()
|
||||
val eventGenerator = ErrorFlowsEventGenerator(
|
||||
parties = parties.map { it.first },
|
||||
notary = notaryNode.nodeInfo.notaryIdentity,
|
||||
currencies = listOf(GBP, USD)
|
||||
)
|
||||
val maxIterations = 10_000
|
||||
startSimulation(eventGenerator, maxIterations)
|
||||
onEnd()
|
||||
}
|
||||
}
|
@ -11,46 +11,16 @@ import jfxtras.resources.JFXtrasFontRoboto
|
||||
import joptsimple.OptionParser
|
||||
import net.corda.client.jfx.model.Models
|
||||
import net.corda.client.jfx.model.observableValue
|
||||
import net.corda.client.mock.EventGenerator
|
||||
import net.corda.client.mock.Generator
|
||||
import net.corda.client.mock.pickOne
|
||||
import net.corda.contracts.asset.Cash
|
||||
import net.corda.core.contracts.Amount
|
||||
import net.corda.core.contracts.GBP
|
||||
import net.corda.core.contracts.USD
|
||||
import net.corda.core.failure
|
||||
import net.corda.core.messaging.FlowHandle
|
||||
import net.corda.core.node.services.ServiceInfo
|
||||
import net.corda.core.node.services.ServiceType
|
||||
import net.corda.core.serialization.OpaqueBytes
|
||||
import net.corda.core.success
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.core.utilities.ALICE
|
||||
import net.corda.core.utilities.BOB
|
||||
import net.corda.core.utilities.DUMMY_NOTARY
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import net.corda.explorer.model.CordaViewModel
|
||||
import net.corda.explorer.model.SettingsModel
|
||||
import net.corda.explorer.views.*
|
||||
import net.corda.explorer.views.cordapps.cash.CashViewer
|
||||
import net.corda.flows.CashExitFlow
|
||||
import net.corda.flows.CashFlowCommand
|
||||
import net.corda.flows.CashIssueFlow
|
||||
import net.corda.flows.CashPaymentFlow
|
||||
import net.corda.flows.IssuerFlow.IssuanceRequester
|
||||
import net.corda.node.driver.PortAllocation
|
||||
import net.corda.node.driver.driver
|
||||
import net.corda.node.services.startFlowPermission
|
||||
import net.corda.node.services.transactions.SimpleNotaryService
|
||||
import net.corda.nodeapi.User
|
||||
import org.apache.commons.lang.SystemUtils
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import org.controlsfx.dialog.ExceptionDialog
|
||||
import tornadofx.App
|
||||
import tornadofx.addStageIcon
|
||||
import tornadofx.find
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Main class for Explorer, you will need Tornado FX to run the explorer.
|
||||
@ -131,6 +101,7 @@ class Main : App(MainView::class) {
|
||||
// Stock Views.
|
||||
registerView<Dashboard>()
|
||||
registerView<TransactionViewer>()
|
||||
registerView<StateMachineViewer>()
|
||||
// CordApps Views.
|
||||
registerView<CashViewer>()
|
||||
// Tools.
|
||||
@ -155,129 +126,7 @@ class Main : App(MainView::class) {
|
||||
* On each iteration, the issuers will execute a Cash Issue or Cash Exit flow (at a 9:1 ratio) and a random party will execute a move of cash to another random party.
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
val portAllocation = PortAllocation.Incremental(20000)
|
||||
driver(portAllocation = portAllocation) {
|
||||
val user = User("user1", "test", permissions = setOf(
|
||||
startFlowPermission<CashPaymentFlow>()
|
||||
))
|
||||
val manager = User("manager", "test", permissions = setOf(
|
||||
startFlowPermission<CashIssueFlow>(),
|
||||
startFlowPermission<CashPaymentFlow>(),
|
||||
startFlowPermission<CashExitFlow>(),
|
||||
startFlowPermission<IssuanceRequester>())
|
||||
)
|
||||
// TODO : Supported flow should be exposed somehow from the node instead of set of ServiceInfo.
|
||||
val notary = startNode(DUMMY_NOTARY.name, advertisedServices = setOf(ServiceInfo(SimpleNotaryService.type)),
|
||||
customOverrides = mapOf("nearestCity" to "Zurich"))
|
||||
val alice = startNode(ALICE.name, rpcUsers = arrayListOf(user),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))),
|
||||
customOverrides = mapOf("nearestCity" to "Milan"))
|
||||
val bob = startNode(BOB.name, rpcUsers = arrayListOf(user),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("cash"))),
|
||||
customOverrides = mapOf("nearestCity" to "Madrid"))
|
||||
val issuerGBP = startNode(X500Name("CN=UK Bank Plc,O=UK Bank Plc,L=London,C=UK"), rpcUsers = arrayListOf(manager),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("issuer.GBP"))),
|
||||
customOverrides = mapOf("nearestCity" to "London"))
|
||||
val issuerUSD = startNode(X500Name("CN=USA Bank Corp,O=USA Bank Corp,L=New York,C=US"), rpcUsers = arrayListOf(manager),
|
||||
advertisedServices = setOf(ServiceInfo(ServiceType.corda.getSubType("issuer.USD"))),
|
||||
customOverrides = mapOf("nearestCity" to "New York"))
|
||||
|
||||
val notaryNode = notary.get()
|
||||
val aliceNode = alice.get()
|
||||
val bobNode = bob.get()
|
||||
val issuerNodeGBP = issuerGBP.get()
|
||||
val issuerNodeUSD = issuerUSD.get()
|
||||
|
||||
arrayOf(notaryNode, aliceNode, bobNode, issuerNodeGBP, issuerNodeUSD).forEach {
|
||||
println("${it.nodeInfo.legalIdentity} started on ${it.configuration.rpcAddress}")
|
||||
}
|
||||
|
||||
val parser = OptionParser("S")
|
||||
val options = parser.parse(*args)
|
||||
if (options.has("S")) {
|
||||
println("Running simulation mode ...")
|
||||
|
||||
// Register with alice to use alice's RPC proxy to create random events.
|
||||
val aliceClient = aliceNode.rpcClientToNode()
|
||||
val aliceConnection = aliceClient.start(user.username, user.password)
|
||||
val aliceRPC = aliceConnection.proxy
|
||||
|
||||
val bobClient = bobNode.rpcClientToNode()
|
||||
val bobConnection = bobClient.start(user.username, user.password)
|
||||
val bobRPC = bobConnection.proxy
|
||||
|
||||
val issuerClientGBP = issuerNodeGBP.rpcClientToNode()
|
||||
val issuerGBPConnection = issuerClientGBP.start(manager.username, manager.password)
|
||||
val issuerRPCGBP = issuerGBPConnection.proxy
|
||||
|
||||
val issuerClientUSD = issuerNodeUSD.rpcClientToNode()
|
||||
val issuerUSDConnection = issuerClientUSD.start(manager.username, manager.password)
|
||||
val issuerRPCUSD = issuerUSDConnection.proxy
|
||||
|
||||
val issuers = mapOf(USD to issuerRPCUSD, GBP to issuerRPCGBP)
|
||||
|
||||
val parties = listOf(aliceNode.nodeInfo.legalIdentity to aliceRPC,
|
||||
bobNode.nodeInfo.legalIdentity to bobRPC,
|
||||
issuerNodeGBP.nodeInfo.legalIdentity to issuerRPCGBP,
|
||||
issuerNodeUSD.nodeInfo.legalIdentity to issuerRPCUSD)
|
||||
|
||||
val eventGenerator = EventGenerator(
|
||||
parties = parties.map { it.first },
|
||||
notary = notaryNode.nodeInfo.notaryIdentity,
|
||||
currencies = listOf(GBP, USD)
|
||||
)
|
||||
|
||||
val maxIterations = 100_000
|
||||
// Log to logger when flow finish.
|
||||
fun FlowHandle<SignedTransaction>.log(seq: Int, name: String) {
|
||||
val out = "[$seq] $name $id :"
|
||||
returnValue.success {
|
||||
Main.log.info("$out ${it.id} ${(it.tx.outputs.first().data as Cash.State).amount}")
|
||||
}.failure {
|
||||
Main.log.info("$out ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Pre allocate some money to each party.
|
||||
eventGenerator.parties.forEach {
|
||||
for (ref in 0..1) {
|
||||
for ((currency, issuer) in issuers) {
|
||||
CashFlowCommand.IssueCash(Amount(1_000_000, currency), OpaqueBytes(ByteArray(1, { ref.toByte() })), it, notaryNode.nodeInfo.notaryIdentity).startFlow(issuer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0..maxIterations) {
|
||||
Thread.sleep(300)
|
||||
// Issuer requests.
|
||||
eventGenerator.issuerGenerator.map { command ->
|
||||
when (command) {
|
||||
is CashFlowCommand.IssueCash -> issuers[command.amount.token]?.let {
|
||||
println("${Instant.now()} [$i] ISSUING ${command.amount} with ref ${command.issueRef} to ${command.recipient}")
|
||||
command.startFlow(it).log(i, "${command.amount.token}Issuer")
|
||||
}
|
||||
is CashFlowCommand.ExitCash -> issuers[command.amount.token]?.let {
|
||||
println("${Instant.now()} [$i] EXITING ${command.amount} with ref ${command.issueRef}")
|
||||
command.startFlow(it).log(i, "${command.amount.token}Exit")
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unsupported command: $command")
|
||||
}
|
||||
}.generate(SplittableRandom())
|
||||
|
||||
// Party pay requests.
|
||||
eventGenerator.moveCashGenerator.combine(Generator.pickOne(parties)) { command, (party, rpc) ->
|
||||
println("${Instant.now()} [$i] SENDING ${command.amount} from $party to ${command.recipient}")
|
||||
command.startFlow(rpc).log(i, party.name.toString())
|
||||
}.generate(SplittableRandom())
|
||||
|
||||
}
|
||||
println("Simulation completed")
|
||||
|
||||
aliceConnection.close()
|
||||
bobConnection.close()
|
||||
issuerGBPConnection.close()
|
||||
issuerUSDConnection.close()
|
||||
}
|
||||
waitForAllNodesToFinish()
|
||||
}
|
||||
val parser = OptionParser("SF")
|
||||
val options = parser.parse(*args)
|
||||
ExplorerSimulation(options)
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
package net.corda.explorer.formatters
|
||||
|
||||
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
|
||||
import net.corda.core.flows.FlowInitiator
|
||||
|
||||
object FlowInitiatorFormatter : Formatter<FlowInitiator> {
|
||||
override fun format(value: FlowInitiator): String {
|
||||
return when (value) {
|
||||
is FlowInitiator.Scheduled -> value.scheduledState.ref.toString() // TODO How do we want to format that?
|
||||
is FlowInitiator.Shell -> "Shell" // TODO We don't have much information about that user.
|
||||
is FlowInitiator.Peer -> PartyNameFormatter.short.format(value.party.name)
|
||||
is FlowInitiator.RPC -> value.username
|
||||
}
|
||||
}
|
||||
|
||||
fun withIcon(value: FlowInitiator): Pair<FontAwesomeIcon, String> {
|
||||
val text = format(value)
|
||||
return when (value) {
|
||||
is FlowInitiator.Scheduled -> Pair(FontAwesomeIcon.CALENDAR, text)
|
||||
is FlowInitiator.Shell -> Pair(FontAwesomeIcon.TERMINAL, text)
|
||||
is FlowInitiator.Peer -> Pair(FontAwesomeIcon.GROUP, text)
|
||||
is FlowInitiator.RPC -> Pair(FontAwesomeIcon.SHARE, text)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package net.corda.explorer.formatters
|
||||
|
||||
import org.apache.commons.lang.StringUtils.splitByCharacterTypeCamelCase
|
||||
|
||||
object FlowNameFormatter {
|
||||
val camelCase = object : Formatter<String> {
|
||||
override fun format(value: String): String {
|
||||
val flowName = value.split('.', '$').last()
|
||||
val split = splitByCharacterTypeCamelCase(flowName).filter { it.compareTo("Flow", true) != 0 } .joinToString(" ")
|
||||
return split
|
||||
}
|
||||
}
|
||||
}
|
@ -195,11 +195,12 @@ fun identicon(secureHash: SecureHash, size: Double): ImageView {
|
||||
return ImageView(IdenticonRenderer.getIdenticon(secureHash)).apply {
|
||||
isPreserveRatio = true
|
||||
fitWidth = size
|
||||
styleClass += "identicon"
|
||||
}
|
||||
}
|
||||
|
||||
fun identiconToolTip(secureHash: SecureHash): Tooltip {
|
||||
return Tooltip(Splitter.fixedLength(16).split("$secureHash").joinToString("\n")).apply {
|
||||
fun identiconToolTip(secureHash: SecureHash, description: String? = null): Tooltip {
|
||||
return Tooltip(Splitter.fixedLength(16).split("${description ?: secureHash}").joinToString("\n")).apply {
|
||||
contentDisplay = ContentDisplay.TOP
|
||||
textAlignment = TextAlignment.CENTER
|
||||
graphic = identicon(secureHash, 90.0)
|
||||
|
@ -31,4 +31,4 @@ abstract class CordaView(title: String? = null) : View(title) {
|
||||
}
|
||||
}
|
||||
|
||||
data class CordaWidget(val name: String, val node: Node)
|
||||
data class CordaWidget(val name: String, val node: Node, val icon: FontAwesomeIcon? = null)
|
@ -1,6 +1,7 @@
|
||||
package net.corda.explorer.views
|
||||
|
||||
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
|
||||
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView
|
||||
import javafx.beans.binding.Bindings
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.scene.Node
|
||||
@ -46,6 +47,7 @@ class Dashboard : CordaView() {
|
||||
selectedView.value = view
|
||||
}
|
||||
}
|
||||
it.icon?.let { graphic = FontAwesomeIconView(it).apply { glyphSize = 30.0 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import javafx.animation.FadeTransition
|
||||
import javafx.animation.TranslateTransition
|
||||
import javafx.beans.binding.Bindings
|
||||
import javafx.beans.property.SimpleObjectProperty
|
||||
import javafx.beans.value.ObservableValue
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.geometry.Bounds
|
||||
import javafx.geometry.Point2D
|
||||
@ -41,6 +42,9 @@ class Network : CordaView() {
|
||||
val notaries by observableList(NetworkIdentityModel::notaries)
|
||||
val peers by observableList(NetworkIdentityModel::parties)
|
||||
val transactions by observableList(TransactionDataModel::partiallyResolvedTransactions)
|
||||
var centralPeer: String? = null
|
||||
private var centralLabel: ObservableValue<Label?>
|
||||
|
||||
// UI components
|
||||
private val myIdentityPane by fxid<BorderPane>()
|
||||
private val notaryList by fxid<VBox>()
|
||||
@ -98,6 +102,7 @@ class Network : CordaView() {
|
||||
}
|
||||
|
||||
val button = button {
|
||||
useMaxWidth = true
|
||||
graphic = vbox {
|
||||
label(PartyNameFormatter.short.format(node.legalIdentity.name)) { font = Font.font(font.family, FontWeight.BOLD, 15.0) }
|
||||
gridpane {
|
||||
@ -114,7 +119,17 @@ class Network : CordaView() {
|
||||
return MapViewComponents(this, button, mapLabel)
|
||||
}
|
||||
|
||||
override fun onDock() {
|
||||
centralLabel = mapLabels.firstOrDefault(myMapLabel, { centralPeer?.contains(it.text, true) ?: false })
|
||||
}
|
||||
|
||||
override fun onUndock() {
|
||||
centralPeer = null
|
||||
centralLabel = myMapLabel
|
||||
}
|
||||
|
||||
init {
|
||||
centralLabel = mapLabels.firstOrDefault(myMapLabel, { centralPeer?.contains(it.text, true) ?: false })
|
||||
myIdentityPane.centerProperty().bind(myButton)
|
||||
Bindings.bindContent(notaryList.children, notaryButtons)
|
||||
Bindings.bindContent(peerList.children, peerButtons)
|
||||
@ -122,7 +137,7 @@ class Network : CordaView() {
|
||||
// Run once when the screen is ready.
|
||||
// TODO : Find a better way to do this.
|
||||
mapPane.heightProperty().addListener { _, old, _ ->
|
||||
if (old == 0.0) myMapLabel.value?.let { mapScrollPane.centerLabel(it) }
|
||||
if (old == 0.0) centralLabel.value?.let { mapScrollPane.centerLabel(it) }
|
||||
}
|
||||
// Listen on zooming gesture, if device has gesture support.
|
||||
mapPane.setOnZoom { zoom(it.zoomFactor, Point2D(it.x, it.y)) }
|
||||
@ -142,6 +157,7 @@ class Network : CordaView() {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO It doesn't work as expected.
|
||||
private fun ScrollPane.centerLabel(label: Label) {
|
||||
this.hvalue = (label.boundsInParent.width / 2 + label.boundsInParent.minX) / mapImageView.layoutBounds.width
|
||||
this.vvalue = (label.boundsInParent.height / 2 + label.boundsInParent.minY) / mapImageView.layoutBounds.height
|
||||
|
@ -23,7 +23,8 @@ import tornadofx.*
|
||||
* TODO : Predictive text?
|
||||
* TODO : Regex?
|
||||
*/
|
||||
class SearchField<T>(private val data: ObservableList<T>, vararg filterCriteria: Pair<String, (T, String) -> Boolean>) : UIComponent() {
|
||||
class SearchField<T>(private val data: ObservableList<T>, vararg filterCriteria: Pair<String, (T, String) -> Boolean>,
|
||||
val disabledFields: List<String> = emptyList()) : UIComponent() {
|
||||
override val root: Parent by fxml()
|
||||
private val textField by fxid<TextField>()
|
||||
private val clearButton by fxid<Node>()
|
||||
@ -34,13 +35,13 @@ class SearchField<T>(private val data: ObservableList<T>, vararg filterCriteria:
|
||||
val text = textField.text
|
||||
val category = searchCategory.value
|
||||
data.filtered { data ->
|
||||
text.isNullOrBlank() || if (category == ALL) {
|
||||
(text.isNullOrBlank() && textField.isVisible) || if (category == ALL) {
|
||||
filterCriteria.any { it.second(data, text) }
|
||||
} else {
|
||||
filterCriteria.toMap()[category]?.invoke(data, text) ?: false
|
||||
}
|
||||
}
|
||||
}, arrayOf<Observable>(textField.textProperty(), searchCategory.valueProperty())))
|
||||
}, arrayOf<Observable>(textField.textProperty(), searchCategory.valueProperty(), textField.visibleProperty())))
|
||||
|
||||
init {
|
||||
clearButton.setOnMouseClicked { event: MouseEvent ->
|
||||
@ -73,5 +74,7 @@ class SearchField<T>(private val data: ObservableList<T>, vararg filterCriteria:
|
||||
}
|
||||
"Filter by $category."
|
||||
})
|
||||
textField.visibleProperty().bind(searchCategory.valueProperty().map { it !in disabledFields })
|
||||
// TODO Maybe it will be better to replace these categories with comboBox? For example Result with choice: succes, in progress, error.
|
||||
}
|
||||
}
|
@ -0,0 +1,294 @@
|
||||
package net.corda.explorer.views
|
||||
|
||||
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
|
||||
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView
|
||||
import javafx.beans.binding.Bindings
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.geometry.HPos
|
||||
import javafx.geometry.Insets
|
||||
import javafx.scene.Parent
|
||||
import javafx.scene.control.Label
|
||||
import javafx.scene.control.TableView
|
||||
import javafx.scene.input.MouseButton
|
||||
import javafx.scene.layout.BorderPane
|
||||
import javafx.scene.layout.GridPane
|
||||
import javafx.scene.layout.VBox
|
||||
import javafx.scene.text.FontWeight
|
||||
import javafx.scene.text.TextAlignment
|
||||
import net.corda.client.jfx.model.StateMachineData
|
||||
import net.corda.client.jfx.model.StateMachineDataModel
|
||||
import net.corda.client.jfx.model.StateMachineStatus
|
||||
import net.corda.client.jfx.model.observableList
|
||||
import net.corda.client.jfx.model.observableValue
|
||||
import net.corda.client.jfx.model.writableValue
|
||||
import net.corda.client.jfx.utils.map
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.crypto.toBase58String
|
||||
import net.corda.core.flows.FlowInitiator
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.explorer.formatters.FlowInitiatorFormatter
|
||||
import net.corda.explorer.formatters.FlowNameFormatter
|
||||
import net.corda.explorer.formatters.PartyNameFormatter
|
||||
import net.corda.explorer.identicon.identicon
|
||||
import net.corda.explorer.identicon.identiconToolTip
|
||||
import net.corda.explorer.model.CordaView
|
||||
import net.corda.explorer.model.CordaViewModel
|
||||
import net.corda.explorer.model.CordaWidget
|
||||
import net.corda.explorer.ui.setCustomCellFactory
|
||||
import tornadofx.*
|
||||
|
||||
|
||||
// TODO Rethink whole idea of showing communication as table, it should be tree view for each StateMachine (with subflows).
|
||||
class StateMachineViewer : CordaView("Flow Triage") {
|
||||
override val root by fxml<BorderPane>()
|
||||
override val icon = FontAwesomeIcon.HEARTBEAT
|
||||
override val widgets = listOf(CordaWidget(title, StateMachineWidget(), icon)).observable()
|
||||
private val allViewTable by fxid<TableView<StateMachineData>>()
|
||||
private val matchingFlowsLabel by fxid<Label>()
|
||||
private val selectedView by writableValue(CordaViewModel::selectedView)
|
||||
|
||||
|
||||
private val stateMachinesAll by observableList(StateMachineDataModel::stateMachinesAll)
|
||||
|
||||
inner private class StateMachineWidget : GridPane() {
|
||||
private val error by observableValue(StateMachineDataModel::error)
|
||||
private val success by observableValue(StateMachineDataModel::success)
|
||||
private val progress by observableValue(StateMachineDataModel::progress)
|
||||
|
||||
init {
|
||||
padding = Insets(0.0, 5.0, 10.0, 10.0)
|
||||
hgap = 5.0
|
||||
styleClass += "chart-plot-background"
|
||||
row {
|
||||
add(makeIconLabel(FontAwesomeIcon.CHECK, "", "-fx-fill: lightslategrey", 30.0))
|
||||
label { textProperty().bind(success.map(Number::toString)) }
|
||||
}
|
||||
row {
|
||||
add(makeIconLabel(FontAwesomeIcon.BOLT, "", "-fx-fill: lightslategrey", 30.0))
|
||||
label { textProperty().bind(error.map(Number::toString)) }
|
||||
}
|
||||
row {
|
||||
add(makeIconLabel(FontAwesomeIcon.ROCKET, "", "-fx-fill: lightslategrey", 30.0))
|
||||
label { textProperty().bind(progress.map(Number::toString)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun makeIconLabel(icon: FontAwesomeIcon, initText: String, customStyle: String? = null, iconSize: Double = 15.0): Label {
|
||||
return label {
|
||||
graphic = FontAwesomeIconView(icon).apply {
|
||||
glyphSize = iconSize
|
||||
textAlignment = TextAlignment.LEFT
|
||||
style = customStyle
|
||||
}
|
||||
text = initText
|
||||
gridpaneConstraints { hAlignment = HPos.CENTER }
|
||||
}
|
||||
}
|
||||
|
||||
fun makeColumns(table: TableView<StateMachineData>, tableItems: ObservableList<StateMachineData>, withResult: Boolean = true) {
|
||||
table.apply {
|
||||
items = tableItems
|
||||
if (withResult) {
|
||||
rowExpander(expandOnDoubleClick = true) {
|
||||
add(StateMachineDetailsView(it).root)
|
||||
}.apply {
|
||||
// Column stays the same size, but we don't violate column restricted resize policy for the whole table view.
|
||||
minWidth = 26.0
|
||||
maxWidth = 26.0
|
||||
}
|
||||
}
|
||||
// TODO Kill that ID column or replace it with something useful when we will have flow audit utilities.
|
||||
// For now it's rather for visual purpose, so you can observe flow.
|
||||
column("ID", StateMachineData::id) {
|
||||
minWidth = 100.0
|
||||
maxWidth = 200.0
|
||||
}.setCustomCellFactory {
|
||||
val toDisplay = it.toString().removeSurrounding("[", "]")
|
||||
label(toDisplay) {
|
||||
val hash = SecureHash.sha256(it.toString())
|
||||
graphic = identicon(hash, 15.0)
|
||||
tooltip = identiconToolTip(hash, toDisplay)
|
||||
}
|
||||
}
|
||||
column("Flow name", StateMachineData::stateMachineName).cellFormat { text = FlowNameFormatter.camelCase.format(it) }
|
||||
column("Initiator", StateMachineData::flowInitiator).setCustomCellFactory {
|
||||
val (initIcon, initText) = FlowInitiatorFormatter.withIcon(it)
|
||||
makeIconLabel(initIcon, initText, "-fx-fill: lightgray")
|
||||
}
|
||||
column("Flow Status", StateMachineData::smmStatus).setCustomCellFactory {
|
||||
val addRm = it.first.value
|
||||
val progress = it.second.value.status ?: "No progress data"
|
||||
if (addRm is StateMachineStatus.Removed) {
|
||||
if (addRm.result.error == null) {
|
||||
makeIconLabel(FontAwesomeIcon.CHECK, "Success", "-fx-fill: green")
|
||||
} else {
|
||||
makeIconLabel(FontAwesomeIcon.BOLT, progress, "-fx-fill: -color-4")
|
||||
}
|
||||
} else {
|
||||
makeIconLabel(FontAwesomeIcon.ROCKET, progress, "-fx-fill: lightslategrey")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val searchField = SearchField(stateMachinesAll,
|
||||
"Flow name" to { sm, s -> sm.stateMachineName.contains(s, true) },
|
||||
"Initiator" to { sm, s -> FlowInitiatorFormatter.format(sm.flowInitiator).contains(s, true) },
|
||||
"Flow Status" to { sm, s ->
|
||||
val stat = sm.smmStatus.second.value?.status ?: "No progress data"
|
||||
stat.contains(s, true)
|
||||
},
|
||||
"Error" to { sm, _ ->
|
||||
val smAddRm = sm.smmStatus.first.value
|
||||
if (smAddRm is StateMachineStatus.Removed)
|
||||
smAddRm.result.error != null
|
||||
else false
|
||||
},
|
||||
"Done" to { sm, _ ->
|
||||
val smAddRm = sm.smmStatus.first.value
|
||||
if (smAddRm is StateMachineStatus.Removed)
|
||||
smAddRm.result.error == null
|
||||
else false
|
||||
},
|
||||
"In progress" to { sm, _ -> sm.smmStatus.first.value !is StateMachineStatus.Removed },
|
||||
disabledFields = listOf("Error", "Done", "In progress")
|
||||
)
|
||||
root.top = searchField.root
|
||||
makeColumns(allViewTable, searchField.filteredData)
|
||||
matchingFlowsLabel.textProperty().bind(Bindings.size(allViewTable.items).map {
|
||||
"$it matching flow${if (it == 1) "" else "s"}"
|
||||
})
|
||||
}
|
||||
|
||||
private inner class StateMachineDetailsView(smmData: StateMachineData) : Fragment() {
|
||||
override val root by fxml<Parent>()
|
||||
private val flowInitiatorGrid by fxid<GridPane>()
|
||||
private val flowResultVBox by fxid<VBox>()
|
||||
|
||||
init {
|
||||
//TODO It would be nice to have flow graph with showing progress steps with subflows + timestamps (left it for second iteration).
|
||||
when (smmData.flowInitiator) {
|
||||
is FlowInitiator.Shell -> makeShellGrid(flowInitiatorGrid) // TODO Extend this when we will have more information on shell user.
|
||||
is FlowInitiator.Peer -> makePeerGrid(flowInitiatorGrid, smmData.flowInitiator as FlowInitiator.Peer)
|
||||
is FlowInitiator.RPC -> makeRPCGrid(flowInitiatorGrid, smmData.flowInitiator as FlowInitiator.RPC)
|
||||
is FlowInitiator.Scheduled -> makeScheduledGrid(flowInitiatorGrid, smmData.flowInitiator as FlowInitiator.Scheduled)
|
||||
}
|
||||
val status = smmData.smmStatus.first.value
|
||||
if (status is StateMachineStatus.Removed) {
|
||||
status.result.match(onValue = { makeResultVBox(flowResultVBox, it) }, onError = { makeErrorVBox(flowResultVBox, it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> makeResultVBox(vbox: VBox, result: T) {
|
||||
if (result is SignedTransaction) {
|
||||
vbox.apply {
|
||||
label("Signed transaction").apply { style { fontWeight = FontWeight.BOLD } }
|
||||
label {
|
||||
style = "-fx-cursor: hand;"
|
||||
setOnMouseClicked {
|
||||
if (it.button == MouseButton.PRIMARY) {
|
||||
selectedView.value = tornadofx.find<TransactionViewer>().apply { txIdToScroll = result.id }
|
||||
}
|
||||
}
|
||||
text = result.id.toString()
|
||||
graphic = identicon(result.id, 30.0)
|
||||
tooltip = identiconToolTip(result.id)
|
||||
}
|
||||
|
||||
}
|
||||
} else if (result != null && result !is Unit) {
|
||||
// TODO Here we could have sth different than SignedTransaction/Unit
|
||||
vbox.apply {
|
||||
label("Flow completed with success. Result: ").apply { style { fontWeight = FontWeight.BOLD } }
|
||||
label(result.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeErrorVBox(vbox: VBox, error: Throwable) {
|
||||
vbox.apply {
|
||||
label {
|
||||
text = error::class.simpleName
|
||||
graphic = FontAwesomeIconView(FontAwesomeIcon.BOLT).apply {
|
||||
glyphSize = 30
|
||||
textAlignment = TextAlignment.CENTER
|
||||
style = "-fx-fill: -color-4"
|
||||
}
|
||||
}
|
||||
label { text = error.message }
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeShellGrid(gridPane: GridPane) {
|
||||
gridPane.apply {
|
||||
label("Flow started by shell user")
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePeerGrid(gridPane: GridPane, initiator: FlowInitiator.Peer) {
|
||||
gridPane.apply {
|
||||
style = "-fx-cursor: hand;"
|
||||
setOnMouseClicked {
|
||||
if (it.button == MouseButton.PRIMARY) {
|
||||
val short = PartyNameFormatter.short.format(initiator.party.name)
|
||||
selectedView.value = tornadofx.find<Network>().apply { centralPeer = short}
|
||||
}
|
||||
}
|
||||
row {
|
||||
label("Peer legal name: ") {
|
||||
gridpaneConstraints { hAlignment = HPos.LEFT }
|
||||
style { fontWeight = FontWeight.BOLD }
|
||||
minWidth = 150.0
|
||||
prefWidth = 150.0
|
||||
}
|
||||
label(initiator.party.name.toString()) { gridpaneConstraints { hAlignment = HPos.LEFT } }
|
||||
}
|
||||
row {
|
||||
label("Owning key: ") {
|
||||
gridpaneConstraints { hAlignment = HPos.LEFT }
|
||||
style { fontWeight = FontWeight.BOLD }
|
||||
minWidth = 150.0
|
||||
prefWidth = 150.0
|
||||
}
|
||||
label(initiator.party.owningKey.toBase58String()) { gridpaneConstraints { hAlignment = HPos.LEFT } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeRPCGrid(gridPane: GridPane, initiator: FlowInitiator.RPC) {
|
||||
gridPane.apply {
|
||||
row {
|
||||
label("RPC user name: ") {
|
||||
gridpaneConstraints { hAlignment = HPos.LEFT }
|
||||
style { fontWeight = FontWeight.BOLD }
|
||||
prefWidth = 150.0
|
||||
}
|
||||
label(initiator.username) { gridpaneConstraints { hAlignment = HPos.LEFT } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeScheduledGrid(gridPane: GridPane, initiator: FlowInitiator.Scheduled) {
|
||||
gridPane.apply {
|
||||
row {
|
||||
label("Scheduled state: ") {
|
||||
gridpaneConstraints { hAlignment = HPos.LEFT }
|
||||
style { fontWeight = FontWeight.BOLD }
|
||||
prefWidth = 150.0
|
||||
}
|
||||
label(initiator.scheduledState.ref.toString()) { gridpaneConstraints { hAlignment = HPos.LEFT } }
|
||||
}
|
||||
row {
|
||||
label("Scheduled at: ") {
|
||||
gridpaneConstraints { hAlignment = HPos.LEFT }
|
||||
style { fontWeight = FontWeight.BOLD }
|
||||
prefWidth = 150.0
|
||||
}
|
||||
label(initiator.scheduledState.scheduledAt.toString()) { gridpaneConstraints { hAlignment = HPos.LEFT } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -53,7 +53,11 @@ class TransactionViewer : CordaView("Transactions") {
|
||||
private val reportingCurrency by observableValue(ReportingCurrencyModel::reportingCurrency)
|
||||
private val myIdentity by observableValue(NetworkIdentityModel::myIdentity)
|
||||
|
||||
override val widgets = listOf(CordaWidget(title, TransactionWidget())).observable()
|
||||
override val widgets = listOf(CordaWidget(title, TransactionWidget(), icon)).observable()
|
||||
|
||||
private var scrollPosition: Int = 0
|
||||
private lateinit var expander: ExpanderColumn<TransactionViewer.Transaction>
|
||||
var txIdToScroll: SecureHash? = null // Passed as param.
|
||||
|
||||
/**
|
||||
* This is what holds data for a single transaction node. Note how a lot of these are nullable as we often simply don't
|
||||
@ -72,6 +76,26 @@ class TransactionViewer : CordaView("Transactions") {
|
||||
|
||||
data class Inputs(val resolved: ObservableList<StateAndRef<ContractState>>, val unresolved: ObservableList<StateRef>)
|
||||
|
||||
override fun onDock() {
|
||||
txIdToScroll?.let {
|
||||
scrollPosition = transactionViewTable.items.indexOfFirst { it.id == txIdToScroll }
|
||||
if (scrollPosition > 0) {
|
||||
expander.toggleExpanded(scrollPosition)
|
||||
val tx = transactionViewTable.items[scrollPosition]
|
||||
transactionViewTable.scrollTo(tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUndock() {
|
||||
if (scrollPosition != 0) {
|
||||
val isExpanded = expander.getExpandedProperty(transactionViewTable.items[scrollPosition])
|
||||
if (isExpanded.value) expander.toggleExpanded(scrollPosition)
|
||||
scrollPosition = 0
|
||||
}
|
||||
txIdToScroll = null
|
||||
}
|
||||
|
||||
/**
|
||||
* We map the gathered data about transactions almost one-to-one to the nodes.
|
||||
*/
|
||||
@ -117,7 +141,10 @@ class TransactionViewer : CordaView("Transactions") {
|
||||
// Transaction table
|
||||
transactionViewTable.apply {
|
||||
items = searchField.filteredData
|
||||
column("Transaction ID", Transaction::id) { maxWidth = 200.0 }.setCustomCellFactory {
|
||||
column("Transaction ID", Transaction::id) {
|
||||
minWidth = 20.0
|
||||
maxWidth = 200.0
|
||||
}.setCustomCellFactory {
|
||||
label("$it") {
|
||||
graphic = identicon(it, 15.0)
|
||||
tooltip = identiconToolTip(it)
|
||||
@ -155,14 +182,15 @@ class TransactionViewer : CordaView("Transactions") {
|
||||
titleProperty.bind(reportingCurrency.map { "Total value ($it equiv)" })
|
||||
}
|
||||
|
||||
rowExpander {
|
||||
expander = rowExpander {
|
||||
add(ContractStatesView(it).root)
|
||||
prefHeight = 400.0
|
||||
}.apply {
|
||||
prefWidth = 26.0
|
||||
isResizable = false
|
||||
// Column stays the same size, but we don't violate column restricted resize policy for the whole table view.
|
||||
// It removes that irritating column at the end of table that does nothing.
|
||||
minWidth = 26.0
|
||||
maxWidth = 26.0
|
||||
}
|
||||
setColumnResizePolicy { true }
|
||||
}
|
||||
matchingTransactionsLabel.textProperty().bind(Bindings.size(transactionViewTable.items).map {
|
||||
"$it matching transaction${if (it == 1) "" else "s"}"
|
||||
@ -186,6 +214,8 @@ class TransactionViewer : CordaView("Transactions") {
|
||||
init {
|
||||
right {
|
||||
label {
|
||||
val hash = SecureHash.randomSHA256()
|
||||
graphic = identicon(hash, 30.0)
|
||||
textProperty().bind(Bindings.size(partiallyResolvedTransactions).map(Number::toString))
|
||||
BorderPane.setAlignment(this, Pos.BOTTOM_RIGHT)
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ class CashViewer : CordaView("Cash") {
|
||||
override val root: BorderPane by fxml()
|
||||
override val icon: FontAwesomeIcon = FontAwesomeIcon.MONEY
|
||||
// View's widget.
|
||||
override val widgets = listOf(CordaWidget("Treasury", CashWidget())).observable()
|
||||
override val widgets = listOf(CordaWidget("Treasury", CashWidget(), icon)).observable()
|
||||
// Left pane
|
||||
private val leftPane: VBox by fxid()
|
||||
private val splitPane: SplitPane by fxid()
|
||||
|
@ -111,6 +111,7 @@
|
||||
-fx-cursor: hand;
|
||||
-fx-background-color: -color-1;
|
||||
-fx-border-color: transparent;
|
||||
-fx-border-radius: 2;
|
||||
}
|
||||
|
||||
.tile .title .text, .tile:expanded .title .text {
|
||||
@ -123,14 +124,15 @@
|
||||
-fx-background-repeat: no-repeat;
|
||||
-fx-background-position: center center;
|
||||
-fx-cursor: hand;
|
||||
-fx-padding: 0px;
|
||||
-fx-padding: 5px;
|
||||
-fx-alignment: bottom-right;
|
||||
-fx-border-color: transparent; /*t r b l */
|
||||
-fx-border-radius: 2;
|
||||
}
|
||||
|
||||
.tile .label {
|
||||
-fx-font-size: 2.4em;
|
||||
-fx-padding: 20px;
|
||||
-fx-font-size: 2.0em;
|
||||
-fx-padding: 10px;
|
||||
-fx-text-fill: -color-0;
|
||||
-fx-font-weight: normal;
|
||||
-fx-text-alignment: right;
|
||||
@ -269,4 +271,24 @@
|
||||
|
||||
.connection-bank-to-regulator {
|
||||
-fx-stroke: red;
|
||||
}
|
||||
}
|
||||
|
||||
/* Other */
|
||||
.identicon {
|
||||
-fx-border-radius: 2;
|
||||
}
|
||||
|
||||
.flow-expanded {
|
||||
-fx-background-color: rgba(255, 255, 255);
|
||||
-fx-background-size: Auto 90%;
|
||||
-fx-background-repeat: no-repeat;
|
||||
-fx-background-position: center center;
|
||||
-fx-border-color: transparent;
|
||||
-fx-border-radius: 2;
|
||||
}
|
||||
|
||||
.flow-expanded:hover {
|
||||
-fx-border-color: -color-3;
|
||||
-fx-border-width: 2;
|
||||
-fx-border-radius: 2;
|
||||
}
|
||||
|
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.layout.ColumnConstraints?>
|
||||
<?import javafx.scene.layout.GridPane?>
|
||||
<?import javafx.scene.layout.RowConstraints?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<GridPane styleClass="flow-expanded" stylesheets="@../css/corda.css" xmlns="http://javafx.com/javafx/8.0.112" xmlns:fx="http://javafx.com/fxml/1">
|
||||
<padding>
|
||||
<Insets bottom="5" left="5" right="5" top="5" />
|
||||
</padding>
|
||||
<GridPane fx:id="flowInitiatorGrid" hgap="10.0" vgap="10.0" maxHeight="Infinity" maxWidth="Infinity" GridPane.fillWidth="true" GridPane.rowIndex="0">
|
||||
<padding>
|
||||
<Insets bottom="5.0" left="5.0" right="10.0" />
|
||||
</padding>
|
||||
</GridPane>
|
||||
<VBox fx:id="flowResultVBox" spacing="10.0" maxHeight="Infinity" maxWidth="Infinity" GridPane.rowIndex="1" GridPane.fillWidth="true">
|
||||
<padding>
|
||||
<Insets bottom="5" left="5" right="5" top="5" />
|
||||
</padding>
|
||||
</VBox>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="450.0" />
|
||||
<ColumnConstraints hgrow="ALWAYS" />
|
||||
</columnConstraints>
|
||||
<rowConstraints>
|
||||
<RowConstraints vgrow="ALWAYS" />
|
||||
<RowConstraints vgrow="ALWAYS" />
|
||||
</rowConstraints>
|
||||
</GridPane>
|
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.TableView?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
|
||||
<BorderPane stylesheets="@../css/corda.css" xmlns="http://javafx.com/javafx/8.0.76-ea"
|
||||
xmlns:fx="http://javafx.com/fxml/1">
|
||||
<padding>
|
||||
<Insets top="5" left="5" right="5" bottom="5"/>
|
||||
</padding>
|
||||
<center>
|
||||
<TableView fx:id="allViewTable" VBox.vgrow="ALWAYS">
|
||||
<columnResizePolicy>
|
||||
<TableView fx:constant="CONSTRAINED_RESIZE_POLICY"/>
|
||||
</columnResizePolicy>
|
||||
</TableView>
|
||||
</center>
|
||||
<bottom>
|
||||
<Label fx:id="matchingFlowsLabel" text="matching flows(s)"/>
|
||||
</bottom>
|
||||
</BorderPane>
|
Loading…
x
Reference in New Issue
Block a user