Adjusted process check. Now checking if there is a produced derived event and if it can create more.
This commit is contained in:
parent
e4e972f36b
commit
82cb245639
@ -0,0 +1,43 @@
|
||||
package no.iktdev.mediaprocessing
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
|
||||
import org.json.JSONArray
|
||||
|
||||
enum class Files(val fileName: String) {
|
||||
MultipleLanguageBased("Events.json")
|
||||
}
|
||||
|
||||
fun Files.getContent(): String? {
|
||||
return this.javaClass.classLoader.getResource(this.fileName)?.readText()
|
||||
}
|
||||
|
||||
fun Files.databaseJsonToEvents(): List<Event> {
|
||||
val content = this.getContent();
|
||||
try {
|
||||
val jarr = JSONArray(content)
|
||||
for (i in 0 until jarr.length()) {
|
||||
val o = jarr.getJSONObject(i)
|
||||
if (o.has("type") && o.getString("type") == "table") {
|
||||
val dataArray = o.getJSONArray("data")
|
||||
|
||||
val events: MutableList<Event> = mutableListOf()
|
||||
for (x in 0 until dataArray.length()) {
|
||||
|
||||
val obj = dataArray.getJSONObject(x)
|
||||
val eventType = obj.getString("event")
|
||||
val dataString = obj.getString("data")
|
||||
dataString.jsonToEvent(eventType).also {
|
||||
events.add(it)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.listeners
|
||||
|
||||
import no.iktdev.eventi.data.referenceId
|
||||
import no.iktdev.mediaprocessing.Files
|
||||
import no.iktdev.mediaprocessing.databaseJsonToEvents
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
||||
class ConvertWorkTaskListenerTest {
|
||||
|
||||
@Test
|
||||
fun validateParsingOfEvents() {
|
||||
val content = Files.MultipleLanguageBased.databaseJsonToEvents()
|
||||
assertThat(content).isNotEmpty()
|
||||
val referenceId = content.firstOrNull()?.referenceId()
|
||||
assertThat(referenceId).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateCreationOfConvertTasks() {
|
||||
val listener: ConvertWorkTaskListener = ConvertWorkTaskListener()
|
||||
val content = Files.MultipleLanguageBased.databaseJsonToEvents().filter { it.eventType in listOf( Events.WorkExtractPerformed, Events.ProcessStarted, Events.WorkConvertCreated, Events.WorkConvertPerformed) }
|
||||
assertThat(listener).isNotNull()
|
||||
val success = content.map { listener.shouldIProcessAndHandleEvent(it, content) to it }
|
||||
assertThat(success.filter { it.first }.size).isGreaterThan(2)
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ package no.iktdev.mediaprocessing.coordinator.tasksV2.listeners
|
||||
import com.google.gson.JsonObject
|
||||
import no.iktdev.eventi.data.dataAs
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.fromJsonWithDeserializer
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@ -11,7 +11,7 @@ class ParseMediaFileStreamsTaskListenerTest {
|
||||
|
||||
@Test
|
||||
fun testParse() {
|
||||
val event = data.fromJsonWithDeserializer(Events.ReadStreamPerformed).dataAs<JsonObject>()
|
||||
val event = data.jsonToEvent(Events.ReadStreamPerformed.event).dataAs<JsonObject>()
|
||||
|
||||
val parser = ParseMediaFileStreamsTaskListener()
|
||||
val result = parser.parseStreams(event)
|
||||
|
||||
@ -6,14 +6,14 @@ import no.iktdev.mediaprocessing.shared.common.contract.data.az
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.AudioPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.EncodingPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.VideoPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.fromJsonWithDeserializer
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EncodeWorkArgumentsMappingTest {
|
||||
|
||||
@Test
|
||||
fun parse() {
|
||||
val event = data.fromJsonWithDeserializer(Events.ParseStreamPerformed)
|
||||
val event = data.jsonToEvent(Events.ParseStreamPerformed.event)
|
||||
val parser = EncodeWorkArgumentsMapping(
|
||||
"potato.mkv",
|
||||
"potato.mp4",
|
||||
|
||||
23
apps/coordinator/src/test/resources/Events.json
Normal file
23
apps/coordinator/src/test/resources/Events.json
Normal file
@ -0,0 +1,23 @@
|
||||
[
|
||||
{"type":"header","version":"5.2.1","comment":"Export to JSON plugin for PHPMyAdmin"},
|
||||
{"type":"database","name":"eventsV4"},
|
||||
{"type":"table","name":"events","database":"eventsV4","data":
|
||||
[
|
||||
{"id":"15349","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"8ca447c5-aad9-40cc-a561-6669e1248d77","event":"event:media-process:started","data":"{\"metadata\":{\"eventId\":\"8ca447c5-aad9-40cc-a561-6669e1248d77\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:46.029447375\",\"source\":\"Coordinator\"},\"data\":{\"type\":\"FLOW\",\"operations\":[\"ENCODE\",\"EXTRACT\",\"CONVERT\"],\"file\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"},\"eventType\":\"ProcessStarted\"}","created":"2025-02-26 22:26:46.047704"},
|
||||
{"id":"15359","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"fc1d6871-b5e3-47ad-96ef-6e63e611dec2","event":"event:work-extract:created","data":"{\"metadata\":{\"derivedFromEventId\":\"17f5ba56-7155-4fac-b787-3371480a08ac\",\"eventId\":\"fc1d6871-b5e3-47ad-96ef-6e63e611dec2\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:57.668593271\",\"source\":\"ExtractWorkTaskListener\"},\"eventType\":\"WorkExtractCreated\",\"data\":{\"arguments\":[\"-c:s\",\"copy\",\"-map\",\"0:s:0\"],\"language\":\"eng\",\"storeFileName\":\"Potetmos\",\"outputFileName\":\"Potetmos.eng.srt\",\"inputFile\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"}}","created":"2025-02-26 22:26:57.695234"},
|
||||
{"id":"15360","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"11abadee-73f5-4b7a-ad28-5ec76ae5e095","event":"event:work-extract:created","data":"{\"metadata\":{\"derivedFromEventId\":\"17f5ba56-7155-4fac-b787-3371480a08ac\",\"eventId\":\"11abadee-73f5-4b7a-ad28-5ec76ae5e095\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:57.668599001\",\"source\":\"ExtractWorkTaskListener\"},\"eventType\":\"WorkExtractCreated\",\"data\":{\"arguments\":[\"-c:s\",\"copy\",\"-map\",\"0:s:1\"],\"language\":\"dan\",\"storeFileName\":\"Potetmos\",\"outputFileName\":\"Potetmos.dan.srt\",\"inputFile\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"}}","created":"2025-02-26 22:26:57.727437"},
|
||||
{"id":"15361","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"9f929171-91be-460f-b6db-0243603eb222","event":"event:work-extract:created","data":"{\"metadata\":{\"derivedFromEventId\":\"17f5ba56-7155-4fac-b787-3371480a08ac\",\"eventId\":\"9f929171-91be-460f-b6db-0243603eb222\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:57.668600721\",\"source\":\"ExtractWorkTaskListener\"},\"eventType\":\"WorkExtractCreated\",\"data\":{\"arguments\":[\"-c:s\",\"copy\",\"-map\",\"0:s:2\"],\"language\":\"swe\",\"storeFileName\":\"Potetmos\",\"outputFileName\":\"Potetmos.swe.srt\",\"inputFile\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"}}","created":"2025-02-26 22:26:57.762233"},
|
||||
{"id":"15362","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"abbba9e0-0491-48a1-bf48-dd0f201cef7f","event":"event:work-extract:created","data":"{\"metadata\":{\"derivedFromEventId\":\"17f5ba56-7155-4fac-b787-3371480a08ac\",\"eventId\":\"abbba9e0-0491-48a1-bf48-dd0f201cef7f\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:57.668602091\",\"source\":\"ExtractWorkTaskListener\"},\"eventType\":\"WorkExtractCreated\",\"data\":{\"arguments\":[\"-c:s\",\"copy\",\"-map\",\"0:s:3\"],\"language\":\"nob\",\"storeFileName\":\"Potetmos\",\"outputFileName\":\"Potetmos.nob.srt\",\"inputFile\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"}}","created":"2025-02-26 22:26:57.792813"},
|
||||
{"id":"15363","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"fe594289-b2c5-4e3e-8827-4ba2988184b6","event":"event:work-extract:created","data":"{\"metadata\":{\"derivedFromEventId\":\"17f5ba56-7155-4fac-b787-3371480a08ac\",\"eventId\":\"fe594289-b2c5-4e3e-8827-4ba2988184b6\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:57.668603331\",\"source\":\"ExtractWorkTaskListener\"},\"eventType\":\"WorkExtractCreated\",\"data\":{\"arguments\":[\"-c:s\",\"copy\",\"-map\",\"0:s:4\"],\"language\":\"fin\",\"storeFileName\":\"Potetmos\",\"outputFileName\":\"Potetmos.fin.srt\",\"inputFile\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"}}","created":"2025-02-26 22:26:57.827601"},
|
||||
{"id":"15364","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"99692869-ba77-4976-8544-02fdc841a06a","event":"event:work-encode:created","data":"{\"metadata\":{\"derivedFromEventId\":\"fb06297e-e5ab-4e2f-bf20-b624d37c2f8b\",\"eventId\":\"99692869-ba77-4976-8544-02fdc841a06a\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:26:57.842844507\",\"source\":\"EncodeWorkTaskListener\"},\"eventType\":\"WorkEncodeCreated\",\"data\":{\"arguments\":[\"-c:v\",\"copy\",\"-vbsf\",\"hevc_mp4toannexb\",\"-acodec\",\"copy\",\"-map\",\"0:v:0\",\"-map\",\"0:a:0\"],\"outputFileName\":\"Potetmos.mp4\",\"inputFile\":\"\/src\/input\/completed\/standalone\/Potetmos\/Potetmos.mkv\"}}","created":"2025-02-26 22:26:57.871043"},
|
||||
{"id":"15365","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"59d1bcda-0a34-48fc-83e3-34547d63abb5","event":"event:work-extract:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"fc1d6871-b5e3-47ad-96ef-6e63e611dec2\",\"eventId\":\"59d1bcda-0a34-48fc-83e3-34547d63abb5\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:15.577829445\",\"source\":\"ExtractService\"},\"eventType\":\"WorkExtractPerformed\",\"data\":{\"language\":\"eng\",\"storeFileName\":\"Potetmos\",\"outputFile\":\"\/src\/cache\/Potetmos.eng.srt\"}}","created":"2025-02-26 22:27:15.581941"},
|
||||
{"id":"15366","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"5a0419e6-572f-4b73-95b8-11af31e6002e","event":"event:work-encode:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"99692869-ba77-4976-8544-02fdc841a06a\",\"eventId\":\"5a0419e6-572f-4b73-95b8-11af31e6002e\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:15.598866765\",\"source\":\"EncodeService\"},\"eventType\":\"WorkEncodePerformed\",\"data\":{\"outputFile\":\"\/src\/cache\/Potetmos.mp4\"}}","created":"2025-02-26 22:27:15.603074"},
|
||||
{"id":"15367","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"4191b02e-1d85-49f3-8cd3-2ca3a424e645","event":"event:work-convert:created","data":"{\"metadata\":{\"derivedFromEventId\":\"59d1bcda-0a34-48fc-83e3-34547d63abb5\",\"eventId\":\"4191b02e-1d85-49f3-8cd3-2ca3a424e645\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:16.058203558\",\"source\":\"ConvertWorkTaskListener\"},\"eventType\":\"WorkConvertCreated\",\"data\":{\"inputFile\":\"\/src\/cache\/Potetmos.eng.srt\",\"language\":\"eng\",\"outputDirectory\":\"\/src\/cache\",\"outputFileName\":\"Potetmos.eng\",\"storeFileName\":\"Potetmos\",\"formats\":[],\"allowOverwrite\":true}}","created":"2025-02-26 22:27:16.091175"},
|
||||
{"id":"15368","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"79f23190-8696-4b4f-b037-98c0a9cd6314","event":"event:work-extract:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"11abadee-73f5-4b7a-ad28-5ec76ae5e095\",\"eventId\":\"79f23190-8696-4b4f-b037-98c0a9cd6314\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:21.73082975\",\"source\":\"ExtractService\"},\"eventType\":\"WorkExtractPerformed\",\"data\":{\"language\":\"dan\",\"storeFileName\":\"Potetmos\",\"outputFile\":\"\/src\/cache\/Potetmos.dan.srt\"}}","created":"2025-02-26 22:27:21.734862"},
|
||||
{"id":"15369","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"aa66dda3-dbb0-4738-9b38-0f10ec6e3c07","event":"event:work-convert:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"4191b02e-1d85-49f3-8cd3-2ca3a424e645\",\"eventId\":\"aa66dda3-dbb0-4738-9b38-0f10ec6e3c07\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:22.029604937\",\"source\":\"ConvertService\"},\"eventType\":\"WorkConvertPerformed\",\"data\":{\"language\":\"eng\",\"baseName\":\"Potetmos\",\"outputFiles\":[\"\/src\/cache\/Potetmos.eng.vtt\",\"\/src\/cache\/Potetmos.eng.smi\"]}}","created":"2025-02-26 22:27:22.033352"},
|
||||
{"id":"15370","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"ec07bb50-1cc1-45b2-a5d7-73b84bfd29d4","event":"event:work-extract:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"9f929171-91be-460f-b6db-0243603eb222\",\"eventId\":\"ec07bb50-1cc1-45b2-a5d7-73b84bfd29d4\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:26.803119861\",\"source\":\"ExtractService\"},\"eventType\":\"WorkExtractPerformed\",\"data\":{\"language\":\"swe\",\"storeFileName\":\"Potetmos\",\"outputFile\":\"\/src\/cache\/Potetmos.swe.srt\"}}","created":"2025-02-26 22:27:26.807279"},
|
||||
{"id":"15371","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"7b6663e4-cc38-4b5e-b0af-32bd1ed3039d","event":"event:work-extract:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"abbba9e0-0491-48a1-bf48-dd0f201cef7f\",\"eventId\":\"7b6663e4-cc38-4b5e-b0af-32bd1ed3039d\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:31.834864505\",\"source\":\"ExtractService\"},\"eventType\":\"WorkExtractPerformed\",\"data\":{\"language\":\"nob\",\"storeFileName\":\"Potetmos\",\"outputFile\":\"\/src\/cache\/Potetmos.nob.srt\"}}","created":"2025-02-26 22:27:31.839143"},
|
||||
{"id":"15372","referenceId":"7aed4397-e286-4787-966e-7d375f43b1e5","eventId":"4b3c17b8-c624-40fb-94ca-33e3657bc6d3","event":"event:work-extract:performed","data":"{\"metadata\":{\"derivedFromEventId\":\"fe594289-b2c5-4e3e-8827-4ba2988184b6\",\"eventId\":\"4b3c17b8-c624-40fb-94ca-33e3657bc6d3\",\"referenceId\":\"7aed4397-e286-4787-966e-7d375f43b1e5\",\"status\":\"Success\",\"created\":\"2025-02-26T22:27:37.017421899\",\"source\":\"ExtractService\"},\"eventType\":\"WorkExtractPerformed\",\"data\":{\"language\":\"fin\",\"storeFileName\":\"Potetmos\",\"outputFile\":\"\/src\/cache\/Potetmos.fin.srt\"}}","created":"2025-02-26 22:27:37.021717"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,7 +1,18 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
data class EventHolder(
|
||||
val referenceId: String,
|
||||
val fileName: String?,
|
||||
val events: List<EventChain>,
|
||||
val created: Long
|
||||
)
|
||||
|
||||
data class EventChain(
|
||||
val eventId: String,
|
||||
val eventName: String,
|
||||
val elements: MutableList<EventChain> = mutableListOf()
|
||||
val created: Long,
|
||||
val success: Boolean,
|
||||
val failure: Boolean,
|
||||
val skipped: Boolean,
|
||||
val events: MutableList<EventChain> = mutableListOf()
|
||||
)
|
||||
@ -1,49 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.ui.service
|
||||
|
||||
import no.iktdev.eventi.data.derivedFromEventId
|
||||
import no.iktdev.eventi.data.eventId
|
||||
import no.iktdev.eventi.data.referenceId
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.database.cal.EventsManager
|
||||
import no.iktdev.mediaprocessing.ui.dto.EventChain
|
||||
import no.iktdev.mediaprocessing.ui.eventsManager
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
//@Service
|
||||
@EnableScheduling
|
||||
class EventExecutionOrderService(
|
||||
@Autowired eventsManager: EventsManager
|
||||
) {
|
||||
|
||||
val collections: MutableMap<String, List<EventChain>> = mutableMapOf()
|
||||
|
||||
@Scheduled(fixedDelay = 5_000)
|
||||
fun pullAvailableEvents() {
|
||||
eventsManager.getAllEvents().onEach { events ->
|
||||
collections[events.first().referenceId()] = events.chained()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun List<Event>.chained(): List<EventChain> {
|
||||
val eventMap = this.associateBy { it.eventId() }
|
||||
val chains = mutableMapOf<String, EventChain>()
|
||||
|
||||
this.forEach { event ->
|
||||
val chain = EventChain(eventId = event.eventId(), eventName = event.eventType.name)
|
||||
chains[event.eventId()] = chain
|
||||
|
||||
if (event.derivedFromEventId() != null && eventMap.containsKey(event.derivedFromEventId())) {
|
||||
val parentChain = chains[event.derivedFromEventId()]
|
||||
parentChain?.elements?.add(chain)
|
||||
}
|
||||
}
|
||||
return chains.values.filter { it.elements.isNotEmpty() }.toList()
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@ -1,27 +1,34 @@
|
||||
package no.iktdev.mediaprocessing.ui.socket
|
||||
|
||||
import no.iktdev.eventi.data.derivedFromEventId
|
||||
import no.iktdev.eventi.data.eventId
|
||||
import no.iktdev.eventi.data.referenceId
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import no.iktdev.eventi.data.*
|
||||
import no.iktdev.eventi.database.toEpochSeconds
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.*
|
||||
import no.iktdev.mediaprocessing.ui.dto.EventChain
|
||||
import no.iktdev.mediaprocessing.ui.dto.EventHolder
|
||||
import no.iktdev.mediaprocessing.ui.eventsManager
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate
|
||||
import org.springframework.stereotype.Controller
|
||||
import java.io.File
|
||||
|
||||
@Controller
|
||||
class ChainedEventsTopic(
|
||||
@Autowired private val template: SimpMessagingTemplate?
|
||||
@Autowired private val template: SimpMessagingTemplate?,
|
||||
) {
|
||||
@MessageMapping("/chained/all")
|
||||
fun sendAllChainedEvents() {
|
||||
val collections: MutableMap<String, List<EventChain>> = mutableMapOf()
|
||||
val holders: MutableList<EventHolder> = mutableListOf()
|
||||
eventsManager.getAllEvents().onEach { events ->
|
||||
collections[events.first().referenceId()] = events.chained()
|
||||
holders.add(EventHolder(
|
||||
referenceId = events.first().referenceId(),
|
||||
fileName = events.findFirstOf(Events.ProcessStarted)?.dataAs<StartEventData>()?.file?.let { File(it).name },
|
||||
events = events.chained(),
|
||||
created = events.chained().firstOrNull()?.created ?: 0
|
||||
))
|
||||
}
|
||||
template?.convertAndSend("/topic/chained/all",collections)
|
||||
template?.convertAndSend("/topic/chained/all", holders)
|
||||
}
|
||||
|
||||
|
||||
@ -33,17 +40,23 @@ class ChainedEventsTopic(
|
||||
this.forEach { event ->
|
||||
val eventId = event.metadata.eventId
|
||||
val derivedFromEventId = event.metadata.derivedFromEventId
|
||||
val chain = chains.getOrPut(eventId) { EventChain(eventId, event.eventType.toString()) }
|
||||
val created = event.metadata.created.toEpochSeconds() * 1000L
|
||||
val chain = chains.getOrPut(eventId) {
|
||||
EventChain(eventId, event.eventType.toString(), created, success = event.isSuccessful(), skipped = event.isSkipped(), failure = event.isFailed())
|
||||
}
|
||||
|
||||
if (derivedFromEventId != null && eventMap.containsKey(derivedFromEventId)) {
|
||||
val parentChain = chains.getOrPut(derivedFromEventId) {
|
||||
EventChain(derivedFromEventId, eventMap[derivedFromEventId]!!.eventType.toString())
|
||||
EventChain(derivedFromEventId, eventMap[derivedFromEventId]!!.eventType.toString(), created, success = event.isSuccessful(), skipped = event.isSkipped(), failure = event.isFailed())
|
||||
}
|
||||
parentChain.elements.add(chain)
|
||||
parentChain.events.add(chain)
|
||||
children.add(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
chains.values.forEach { chain -> chain.events.sortBy { it.created }}
|
||||
|
||||
return chains.values.filter { it.eventId !in children }
|
||||
.sortedBy { it.created }
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate
|
||||
import org.springframework.stereotype.Controller
|
||||
|
||||
@Controller
|
||||
class EventsTableTopic(
|
||||
class PersistentEventsTableTopic(
|
||||
@Autowired private val template: SimpMessagingTemplate?,
|
||||
//@Autowired private val persistentEventsTableService: PersistentEventsTableService
|
||||
) {
|
||||
2451
apps/ui/web/package-lock.json
generated
2451
apps/ui/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,7 @@
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/styled-components": "^5.1.27",
|
||||
"react": "^18.2.0",
|
||||
"react-d3-tree": "^3.6.5",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.1",
|
||||
"react-router-dom": "^6.15.0",
|
||||
|
||||
@ -1,6 +1,19 @@
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
|
||||
export function toUnixTimestamp({ timestamp }: { timestamp?: number }) {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'short' });
|
||||
const year = date.getFullYear();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${day}.${month}.${year} ${hours}.${minutes}`;
|
||||
}
|
||||
|
||||
export function UnixTimestamp({ timestamp }: { timestamp?: number }) {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../store";
|
||||
import IconArrowUp from '@mui/icons-material/ArrowUpward';
|
||||
import IconArrowDown from '@mui/icons-material/ArrowDownward';
|
||||
import { Table, TableHead, TableRow, TableCell, TableBody, Typography, Box, useTheme, TableContainer } from "@mui/material";
|
||||
|
||||
export interface TablePropetyConfig {
|
||||
label: string
|
||||
accessor: string
|
||||
}
|
||||
|
||||
export interface TableCellCustomizer<T> {
|
||||
(accessor: string, data: T): JSX.Element | null
|
||||
}
|
||||
|
||||
type NullableTableRowActionEvents<T> = TableRowActionEvents<T> | null;
|
||||
export interface TableRowActionEvents<T> {
|
||||
click: (row: T) => void;
|
||||
doubleClick: (row: T) => void;
|
||||
contextMenu?: (row: T, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
|
||||
export default function SimpleTable<T>({ items, columns, customizer, onRowClickedEvent }: { items: Array<T>, columns: Array<TablePropetyConfig>, customizer?: TableCellCustomizer<T>, onRowClickedEvent?: TableRowActionEvents<T> }) {
|
||||
const muiTheme = useTheme();
|
||||
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [orderBy, setOrderBy] = useState<string>('');
|
||||
const [selectedRow, setSelectedRow] = useState<T | null>(null);
|
||||
|
||||
const tableRowSingleClicked = (row: T | null) => {
|
||||
setSelectedRow(row);
|
||||
if (row && onRowClickedEvent) {
|
||||
onRowClickedEvent.click(row);
|
||||
}
|
||||
}
|
||||
const tableRowDoubleClicked = (row: T | null) => {
|
||||
setSelectedRow(row);
|
||||
if (row && onRowClickedEvent) {
|
||||
onRowClickedEvent.doubleClick(row);
|
||||
}
|
||||
}
|
||||
|
||||
const tableRowContextMenu = (e: React.MouseEvent<HTMLTableRowElement, MouseEvent> , row: T | null) => {
|
||||
if (row && onRowClickedEvent && onRowClickedEvent.contextMenu) {
|
||||
e.preventDefault()
|
||||
onRowClickedEvent.contextMenu(row, e.pageX, e.pageY)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (property: string) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
const compareValues = (a: any, b: any, orderBy: string) => {
|
||||
if (typeof a[orderBy] === 'string') {
|
||||
return a[orderBy].localeCompare(b[orderBy]);
|
||||
} else if (typeof a[orderBy] === 'number') {
|
||||
return a[orderBy] - b[orderBy];
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortedData = items.slice().sort((a, b) => {
|
||||
if (order === 'asc') {
|
||||
return compareValues(a, b, orderBy);
|
||||
} else {
|
||||
return compareValues(b, a, orderBy);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
handleSort(columns[0].accessor)
|
||||
}, [])
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column", // Bruk column-fleksretning
|
||||
height: "100%",
|
||||
overflow: "hidden"
|
||||
}}>
|
||||
<TableContainer sx={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
position: "relative", // Legg til denne linjen for å justere layout
|
||||
maxHeight: "100%" // Legg til denne linjen for å begrense høyden
|
||||
}}>
|
||||
<Table>
|
||||
<TableHead sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
backgroundColor: muiTheme.palette.background.paper,
|
||||
}}>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.accessor} onClick={() => handleSort(column.accessor)} sx={{ cursor: "pointer" }}>
|
||||
<Box display="flex">
|
||||
{orderBy === column.accessor ?
|
||||
(order === "asc" ? (<IconArrowDown />) : (<IconArrowUp />)) : (
|
||||
<IconArrowDown sx={{ color: "transparent" }} />
|
||||
)
|
||||
}
|
||||
<Typography>{column.label}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody sx={{
|
||||
overflowY: "scroll"
|
||||
}}>
|
||||
{sortedData?.map((row: T, rowIndex: number) => (
|
||||
<TableRow key={rowIndex}
|
||||
onClick={() => tableRowSingleClicked(row)}
|
||||
onDoubleClick={() => tableRowDoubleClicked(row)}
|
||||
onContextMenu={(e) => {
|
||||
tableRowContextMenu(e, row);
|
||||
tableRowSingleClicked(row);
|
||||
}}
|
||||
style={{ cursor: "pointer", backgroundColor: selectedRow === row ? muiTheme.palette.action.selected : '' }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.accessor}>
|
||||
{customizer && customizer(column.accessor, row) !== null
|
||||
? customizer(column.accessor, row)
|
||||
: <Typography variant='body1'>{(row as any)[column.accessor]}</Typography>}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -1,41 +1,68 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useStompClient } from 'react-stomp-hooks';
|
||||
import { RootState } from "../store";
|
||||
import { useWsSubscription } from "../ws/subscriptions";
|
||||
import { set } from "../store/persistent-events-slice";
|
||||
import { EventsObjectListResponse } from "../../types";
|
||||
import IconRefresh from '@mui/icons-material/Refresh'
|
||||
import { Button } from "@mui/material";
|
||||
import { Box, Button, Typography, useTheme } from "@mui/material";
|
||||
import { Tree } from "react-d3-tree";
|
||||
import { EventChain, EventGroup, EventGroups, set } from "../store/chained-events-slice";
|
||||
import { toUnixTimestamp, UnixTimestamp } from "../features/UxTc";
|
||||
import { TableCellCustomizer, TablePropetyConfig } from "../features/table/table";
|
||||
import ExpandableTable, { ExpandableItem } from "../features/table/expandableTable";
|
||||
import { CustomNodeElementProps } from 'react-d3-tree';
|
||||
|
||||
|
||||
interface RawNodeDatum {
|
||||
name: string;
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
children?: RawNodeDatum[];
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
// Transformasjonsfunksjon for EventChain
|
||||
const transformEventChain = (eventChain: EventChain): RawNodeDatum => ({
|
||||
name: eventChain.eventName,
|
||||
attributes: {
|
||||
//eventId: eventChain.eventId,
|
||||
created: toUnixTimestamp({ timestamp: eventChain.created }) ?? "",
|
||||
success: eventChain.success,
|
||||
skipped: eventChain.skipped,
|
||||
failure: eventChain.failure,
|
||||
childrens: eventChain.events.length
|
||||
},
|
||||
fill: "white",
|
||||
children: eventChain.events.map(transformEventChain),
|
||||
});
|
||||
|
||||
// Transformasjonsfunksjon for EventGroups
|
||||
const transformEventGroups = (group: EventGroup): RawNodeDatum[] => {
|
||||
return group.events.map(transformEventChain);
|
||||
}
|
||||
|
||||
interface EventGroupToTreeView extends EventGroup {
|
||||
underView: JSX.Element
|
||||
}
|
||||
|
||||
export default function EventsChainPage() {
|
||||
const muiTheme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const client = useStompClient();
|
||||
const cursor = useSelector((state: RootState) => state.persistentEvents)
|
||||
const cursor = useSelector((state: RootState) => state.chained)
|
||||
const [useReferenceId, setUseReferenceId] = useState<string | null>(null);
|
||||
const [treeData, setTreeData] = useState<RawNodeDatum[] | null>(null);
|
||||
|
||||
function log(data: any) {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
useWsSubscription("/topic/chained/all", (response) => {
|
||||
console.log(response)
|
||||
useWsSubscription<Array<EventGroup>>("/topic/chained/all", (response) => {
|
||||
dispatch(set(response))
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
// Kjør din funksjon her når komponenten lastes inn for første gang
|
||||
// Sjekk om cursor er null
|
||||
if (cursor.items === null && client !== null) {
|
||||
console.log(cursor)
|
||||
// Kjør din funksjon her når cursor er null og client ikke er null
|
||||
if (Object.keys(cursor).length === 0) {
|
||||
client?.publish({
|
||||
destination: "/app/chained/all"
|
||||
});
|
||||
|
||||
// Alternativt, du kan dispatche en Redux handling her
|
||||
// dispatch(fetchDataAction()); // Eksempel på å dispatche en handling
|
||||
}
|
||||
}, [cursor, client, dispatch]);
|
||||
|
||||
@ -45,14 +72,124 @@ export default function EventsChainPage() {
|
||||
"body": "Potato"
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (useReferenceId) {
|
||||
const eventGroup = cursor.groups.find((group) => group.referenceId === useReferenceId);
|
||||
if (eventGroup) {
|
||||
const data = transformEventGroups(eventGroup);
|
||||
console.log({
|
||||
"info": "Tree data",
|
||||
"data": data
|
||||
})
|
||||
setTreeData(data);
|
||||
}
|
||||
}
|
||||
}, [useReferenceId, cursor])
|
||||
|
||||
const createTableCell: TableCellCustomizer<EventGroup> = (accessor, data) => {
|
||||
switch (accessor) {
|
||||
case "created": {
|
||||
if (typeof data[accessor] === "number") {
|
||||
const timestampObject = { timestamp: data[accessor] as number }; // Opprett et objekt med riktig struktur
|
||||
return UnixTimestamp(timestampObject);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Array<TablePropetyConfig> = [
|
||||
{ label: "ReferenceId", accessor: "referenceId" },
|
||||
{ label: "File", accessor: "fileName" },
|
||||
{ label: "Created", accessor: "created" },
|
||||
];
|
||||
|
||||
|
||||
function renderCustomNodeElement(nodeData: CustomNodeElementProps): JSX.Element {
|
||||
const attr = nodeData.nodeDatum.attributes;
|
||||
const nodeColor = attr?.success ? "green" : attr?.failure ? "red" : "yellow";
|
||||
return (<>
|
||||
<g>
|
||||
<circle r="15" fill={nodeColor} onClick={nodeData.toggleNode} />
|
||||
<text fill="white" stroke="white" strokeWidth="0" x="20">
|
||||
{nodeData.nodeDatum.name}
|
||||
</text>
|
||||
{attr?.created && (
|
||||
<text fill="lightgray" x="20" dy="20" strokeWidth="0">
|
||||
Created: {attr?.created}
|
||||
</text>
|
||||
)}
|
||||
{attr?.success && (
|
||||
<text fill="lightgray" x="20" dy="40" strokeWidth="0">
|
||||
Success
|
||||
</text>
|
||||
)}
|
||||
{attr?.failure && (
|
||||
<text fill="lightgray" x="20" dy="40" strokeWidth="0">
|
||||
Failure
|
||||
</text>
|
||||
)}
|
||||
{attr?.skipped && (
|
||||
<text fill="lightgray" x="20" dy="40" strokeWidth="0">
|
||||
Skipped
|
||||
</text>
|
||||
)}
|
||||
{attr?.childrens && (
|
||||
<text fill="lightgray" x="20" dy="60" strokeWidth="0">
|
||||
{attr?.childrens} derived {Number(attr?.childrens) > 1 ? "events" : "event"}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
</>);
|
||||
}
|
||||
|
||||
function renderExpandableItem(item: EventGroup): ExpandableItem<EventGroup> | null {
|
||||
return {
|
||||
tag: item.referenceId,
|
||||
expandElement: (() => {
|
||||
const data = transformEventGroups(item);
|
||||
return (
|
||||
<>
|
||||
{(data) ? (
|
||||
<div id="treeWrapper" style={{ width: '100%', height: '60vh', }}>
|
||||
<Tree data={data} orientation="vertical" separation={{
|
||||
nonSiblings: 3,
|
||||
siblings: 2
|
||||
}}
|
||||
renderCustomNodeElement={renderCustomNodeElement}
|
||||
/>
|
||||
</div>
|
||||
) : <Typography>Tree data not available</Typography>}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
startIcon={ <IconRefresh /> }
|
||||
startIcon={<IconRefresh />}
|
||||
onClick={onRefresh} sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}}>Refresh</Button >
|
||||
}}>Refresh
|
||||
</Button>
|
||||
<Box display="block">
|
||||
<Box sx={{
|
||||
display: "block",
|
||||
height: "calc(100% - 120px)",
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
width: "100%"
|
||||
}}>
|
||||
<ExpandableTable items={cursor?.groups ?? []} columns={columns} cellCustomizer={createTableCell} expandableRender={renderExpandableItem} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { UnixTimestamp } from '../features/UxTc';
|
||||
import { Box, Button, Typography, useTheme } from '@mui/material';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '../store';
|
||||
import SimpleTable, { TableCellCustomizer, TablePropetyConfig, TableRowActionEvents } from '../features/table';
|
||||
import { TableCellCustomizer, TablePropetyConfig, TableRowActionEvents } from '../features/table/table';
|
||||
import { useStompClient } from 'react-stomp-hooks';
|
||||
import { useWsSubscription } from '../ws/subscriptions';
|
||||
import { updateItems } from '../store/explorer-slice';
|
||||
@ -14,6 +14,7 @@ import IconHome from '@mui/icons-material/Home';
|
||||
import { ExplorerItem, ExplorerCursor, ExplorerItemType } from '../../types';
|
||||
import ContextMenu, { ContextMenuActionEvent, ContextMenuItem } from '../features/ContextMenu';
|
||||
import { canConvert, canEncode, canExtract } from '../../fileUtil';
|
||||
import SimpleTable from '../features/table/sortableTable';
|
||||
|
||||
|
||||
const createTableCell: TableCellCustomizer<ExplorerItem> = (accessor, data) => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import SimpleTable, { TableCellCustomizer, TablePropetyConfig } from "../features/table"
|
||||
import { RootState } from "../store";
|
||||
import { useEffect } from "react";
|
||||
import { useStompClient } from "react-stomp-hooks";
|
||||
@ -7,6 +6,8 @@ import { Box, Button, IconButton, Typography, useTheme } from "@mui/material";
|
||||
import IconRefresh from '@mui/icons-material/Refresh'
|
||||
import IconCompleted from '@mui/icons-material/Check'
|
||||
import IconWorking from '@mui/icons-material/Engineering';
|
||||
import { TablePropetyConfig } from "../features/table/table";
|
||||
import SimpleTable from "../features/table/sortableTable";
|
||||
|
||||
const columns: Array<TablePropetyConfig> = [
|
||||
{ label: "Title", accessor: "givenTitle" },
|
||||
@ -54,7 +55,7 @@ export default function LaunchPage() {
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
}}>
|
||||
<Button
|
||||
<Button
|
||||
startIcon={ <IconRefresh /> }
|
||||
onClick={onRefresh} sx={{
|
||||
borderRadius: 5,
|
||||
|
||||
@ -4,6 +4,7 @@ import explorerSlice from './store/explorer-slice';
|
||||
import kafkaItemsFlatSlice from './store/kafka-items-flat-slice';
|
||||
import contextMenuSlice from './store/context-menu-slice';
|
||||
import persistentEventsSlice from './store/persistent-events-slice';
|
||||
import chainedEventsSlice from './store/chained-events-slice';
|
||||
|
||||
|
||||
export const store = configureStore({
|
||||
@ -12,7 +13,8 @@ export const store = configureStore({
|
||||
explorer: explorerSlice,
|
||||
kafkaComposedFlat: kafkaItemsFlatSlice,
|
||||
contextMenu: contextMenuSlice,
|
||||
persistentEvents: persistentEventsSlice
|
||||
persistentEvents: persistentEventsSlice,
|
||||
chained: chainedEventsSlice,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ const root = ReactDOM.createRoot(
|
||||
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/ws`;
|
||||
const wsUrl = "http://localhost:8080/ws" //`${protocol}//${host}/ws`;
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
|
||||
@ -21,6 +21,10 @@ inline fun <reified T: Event> List<Event>.findFirstEventOf(): T? {
|
||||
} else null
|
||||
}
|
||||
|
||||
inline fun List<Event>.findFirstOf(events: Events): Event? {
|
||||
return this.firstOrNull { it.eventType == events }
|
||||
}
|
||||
|
||||
inline fun <reified T: Event> List<Event>.findEventsOf(): List<T> {
|
||||
return this.filterIsInstance<T>().map { it }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user