Refactor server API
This commit is contained in:
parent
d6c974fcbc
commit
20b20a621f
@ -12,7 +12,7 @@ val fxVersion by extra("11")
|
||||
|
||||
allprojects {
|
||||
group = "space.kscience"
|
||||
version = "0.3.0-dev-4"
|
||||
version = "0.3.0-dev-5"
|
||||
}
|
||||
|
||||
subprojects {
|
||||
|
@ -1,5 +1,8 @@
|
||||
package space.kscience.visionforge.examples
|
||||
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.html.*
|
||||
import space.kscience.dataforge.context.Global
|
||||
import space.kscience.dataforge.context.fetch
|
||||
@ -9,51 +12,54 @@ import space.kscience.visionforge.html.formFragment
|
||||
import space.kscience.visionforge.onPropertyChange
|
||||
import space.kscience.visionforge.server.close
|
||||
import space.kscience.visionforge.server.openInBrowser
|
||||
import space.kscience.visionforge.server.serve
|
||||
import space.kscience.visionforge.server.visionPage
|
||||
|
||||
fun main() {
|
||||
val visionManager = Global.fetch(VisionManager)
|
||||
|
||||
val server = visionManager.serve {
|
||||
page(VisionPage.scriptHeader("js/visionforge-playground.js")) {
|
||||
val form = formFragment("form") {
|
||||
label {
|
||||
htmlFor = "fname"
|
||||
+"First name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "fname"
|
||||
name = "fname"
|
||||
value = "John"
|
||||
}
|
||||
br()
|
||||
label {
|
||||
htmlFor = "lname"
|
||||
+"Last name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "lname"
|
||||
name = "lname"
|
||||
value = "Doe"
|
||||
}
|
||||
br()
|
||||
br()
|
||||
input {
|
||||
type = InputType.submit
|
||||
value = "Submit"
|
||||
}
|
||||
}
|
||||
val server = embeddedServer(CIO, 7777, "localhost") {
|
||||
|
||||
vision("form") { form }
|
||||
form.onPropertyChange {
|
||||
println(this)
|
||||
routing {
|
||||
visionPage(visionManager, VisionPage.scriptHeader("js/visionforge-playground.js")) {
|
||||
val form = formFragment("form") {
|
||||
label {
|
||||
htmlFor = "fname"
|
||||
+"First name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "fname"
|
||||
name = "fname"
|
||||
value = "John"
|
||||
}
|
||||
br()
|
||||
label {
|
||||
htmlFor = "lname"
|
||||
+"Last name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "lname"
|
||||
name = "lname"
|
||||
value = "Doe"
|
||||
}
|
||||
br()
|
||||
br()
|
||||
input {
|
||||
type = InputType.submit
|
||||
value = "Submit"
|
||||
}
|
||||
}
|
||||
|
||||
vision("form") { form }
|
||||
form.onPropertyChange {
|
||||
println(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(false)
|
||||
|
||||
server.openInBrowser()
|
||||
|
||||
|
@ -6,6 +6,7 @@ import space.kscience.visionforge.html.ResourceLocation
|
||||
import space.kscience.visionforge.html.VisionPage
|
||||
import space.kscience.visionforge.html.importScriptHeader
|
||||
import space.kscience.visionforge.makeFile
|
||||
import space.kscience.visionforge.visionManager
|
||||
import java.awt.Desktop
|
||||
import java.nio.file.Path
|
||||
|
||||
@ -16,7 +17,7 @@ public fun makeVisionFile(
|
||||
show: Boolean = true,
|
||||
content: HtmlVisionFragment,
|
||||
): Unit {
|
||||
val actualPath = VisionPage(Global, content = content).makeFile(path) { actualPath ->
|
||||
val actualPath = VisionPage(Global.visionManager, content = content).makeFile(path) { actualPath ->
|
||||
mapOf(
|
||||
"title" to VisionPage.title(title),
|
||||
"playground" to VisionPage.importScriptHeader("js/visionforge-playground.js", resourceLocation, actualPath),
|
||||
|
@ -1,26 +1,28 @@
|
||||
package ru.mipt.npm.sat
|
||||
|
||||
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.http.content.resources
|
||||
import io.ktor.server.http.content.static
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.html.div
|
||||
import kotlinx.html.h1
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.fetch
|
||||
import space.kscience.dataforge.meta.Null
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.visionforge.Colors
|
||||
import space.kscience.visionforge.html.VisionPage
|
||||
import space.kscience.visionforge.server.close
|
||||
import space.kscience.visionforge.server.openInBrowser
|
||||
import space.kscience.visionforge.server.serve
|
||||
import space.kscience.visionforge.server.visionPage
|
||||
import space.kscience.visionforge.solid.*
|
||||
import space.kscience.visionforge.three.threeJsHeader
|
||||
import space.kscience.visionforge.visionManager
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
@OptIn(DFExperimental::class)
|
||||
fun main() {
|
||||
val satContext = Context("sat") {
|
||||
plugin(Solids)
|
||||
@ -35,14 +37,21 @@ fun main() {
|
||||
}
|
||||
}
|
||||
|
||||
val server = satContext.visionManager.serve {
|
||||
page(VisionPage.threeJsHeader, VisionPage.styleSheetHeader("css/styles.css")) {
|
||||
div("flex-column") {
|
||||
h1 { +"Satellite detector demo" }
|
||||
vision { sat }
|
||||
val server = embeddedServer(CIO,7777, "localhost") {
|
||||
routing {
|
||||
|
||||
static {
|
||||
resources()
|
||||
}
|
||||
|
||||
visionPage(solids.visionManager, VisionPage.threeJsHeader, VisionPage.styleSheetHeader("css/styles.css")) {
|
||||
div("flex-column") {
|
||||
h1 { +"Satellite detector demo" }
|
||||
vision { sat }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(false)
|
||||
|
||||
server.openInBrowser()
|
||||
|
||||
|
@ -1,6 +1,14 @@
|
||||
package space.kscience.visionforge.jupyter
|
||||
|
||||
import io.ktor.http.URLProtocol
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.ApplicationEngine
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.util.url
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.stream.createHTML
|
||||
@ -13,11 +21,14 @@ import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.meta.get
|
||||
import space.kscience.dataforge.meta.int
|
||||
import space.kscience.dataforge.meta.string
|
||||
import space.kscience.visionforge.VisionManager
|
||||
import space.kscience.visionforge.html.HtmlFormFragment
|
||||
import space.kscience.visionforge.html.HtmlVisionFragment
|
||||
import space.kscience.visionforge.html.VisionCollector
|
||||
import space.kscience.visionforge.html.visionFragment
|
||||
import space.kscience.visionforge.server.VisionServer
|
||||
import space.kscience.visionforge.server.serve
|
||||
import space.kscience.visionforge.server.VisionRouteConfiguration
|
||||
import space.kscience.visionforge.server.require
|
||||
import space.kscience.visionforge.server.serveVisionData
|
||||
import space.kscience.visionforge.visionManager
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.random.Random
|
||||
@ -34,10 +45,14 @@ internal fun TagConsumer<*>.renderScriptForId(id: String) {
|
||||
* A handler class that includes a server and common utilities
|
||||
*/
|
||||
public class VFForNotebook(override val context: Context) : ContextAware, CoroutineScope {
|
||||
|
||||
public val visionManager: VisionManager = context.visionManager
|
||||
|
||||
private val configuration = VisionRouteConfiguration(visionManager)
|
||||
|
||||
private var counter = 0
|
||||
|
||||
private var engine: ApplicationEngine? = null
|
||||
private var server: VisionServer? = null
|
||||
|
||||
public var isolateFragments: Boolean = false
|
||||
|
||||
@ -47,16 +62,15 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
isolateFragments = true
|
||||
}
|
||||
|
||||
public fun isServerRunning(): Boolean = server != null
|
||||
public fun isServerRunning(): Boolean = engine != null
|
||||
|
||||
public fun html(block: TagConsumer<*>.() -> Unit): MimeTypedResult = HTML(createHTML().apply(block).finalize())
|
||||
|
||||
public fun startServer(
|
||||
host: String = context.properties["visionforge.host"].string ?: "localhost",
|
||||
port: Int = context.properties["visionforge.port"].int ?: VisionServer.DEFAULT_PORT,
|
||||
configuration: VisionServer.() -> Unit = {},
|
||||
port: Int = context.properties["visionforge.port"].int ?: VisionRouteConfiguration.DEFAULT_PORT,
|
||||
): MimeTypedResult = html {
|
||||
if (server != null) {
|
||||
if (engine != null) {
|
||||
p {
|
||||
style = "color: red;"
|
||||
+"Stopping current VisionForge server"
|
||||
@ -64,10 +78,9 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
}
|
||||
|
||||
engine?.stop(1000, 2000)
|
||||
engine = context.visionManager.serve(host, port) {
|
||||
configuration()
|
||||
server = this
|
||||
}.start()
|
||||
engine = context.embeddedServer(CIO, port, host) {
|
||||
install(WebSockets)
|
||||
}.start(false)
|
||||
|
||||
p {
|
||||
style = "color: blue;"
|
||||
@ -80,20 +93,46 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
logger.info { "Stopping VisionForge server" }
|
||||
stop(1000, 2000)
|
||||
engine = null
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun produceHtmlString(
|
||||
fragment: HtmlVisionFragment,
|
||||
): String = createHTML().apply {
|
||||
val server = server
|
||||
val id = "fragment[${fragment.hashCode()}/${Random.nextUInt()}]"
|
||||
div {
|
||||
this.id = id
|
||||
if (server != null) {
|
||||
val engine = engine
|
||||
if (engine != null) {
|
||||
//if server exist, serve dynamically
|
||||
server.serveVisionsFromFragment(consumer, "content-${counter++}", fragment)
|
||||
//server.serveVisionsFromFragment(consumer, "content-${counter++}", fragment)
|
||||
val cellRoute = "content-${counter++}"
|
||||
|
||||
val collector: VisionCollector = mutableMapOf()
|
||||
|
||||
val url = engine.environment.connectors.first().let {
|
||||
url{
|
||||
protocol = URLProtocol.WS
|
||||
host = it.host
|
||||
port = it.port
|
||||
pathSegments = listOf(cellRoute, "ws")
|
||||
}
|
||||
}
|
||||
|
||||
visionFragment(
|
||||
context,
|
||||
embedData = true,
|
||||
updatesUrl = url,
|
||||
collector = collector,
|
||||
fragment = fragment
|
||||
)
|
||||
|
||||
engine.application.require(Routing) {
|
||||
route(cellRoute) {
|
||||
serveVisionData(TODO(), collector)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
//if not, use static rendering
|
||||
visionFragment(context, fragment = fragment)
|
||||
|
@ -48,7 +48,7 @@ public abstract class VFIntegrationBase(final override val context: Context) : J
|
||||
|
||||
render<Vision> { vision ->
|
||||
handler.produceHtml {
|
||||
vision { vision }
|
||||
vision(vision)
|
||||
}
|
||||
|
||||
}
|
||||
@ -83,7 +83,7 @@ public abstract class VFIntegrationBase(final override val context: Context) : J
|
||||
}
|
||||
}
|
||||
fragment(fragment.formBody)
|
||||
vision { fragment.vision }
|
||||
vision(fragment.vision)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,10 +2,11 @@ package space.kscience.visionforge.html
|
||||
|
||||
import kotlinx.html.*
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Global
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.NameToken
|
||||
import space.kscience.dataforge.names.asName
|
||||
import space.kscience.visionforge.Vision
|
||||
import space.kscience.visionforge.VisionManager
|
||||
|
||||
@ -14,31 +15,48 @@ public typealias HtmlVisionFragment = VisionTagConsumer<*>.() -> Unit
|
||||
@DFExperimental
|
||||
public fun HtmlVisionFragment(content: VisionTagConsumer<*>.() -> Unit): HtmlVisionFragment = content
|
||||
|
||||
public typealias VisionCollector = MutableMap<Name, Pair<VisionOutput, Vision>>
|
||||
|
||||
|
||||
/**
|
||||
* Render a fragment in the given consumer and return a map of extracted visions
|
||||
* @param context a context used to create a vision fragment
|
||||
* @param embedData embed Vision initial state in the HTML
|
||||
* @param fetchDataUrl fetch data after first render from given url
|
||||
* @param fetchUpdatesUrl receive push updates from the server at given url
|
||||
* @param updatesUrl receive push updates from the server at given url
|
||||
* @param idPrefix a prefix to be used before vision ids
|
||||
* @param renderScript if true add rendering script after the fragment
|
||||
*/
|
||||
public fun TagConsumer<*>.visionFragment(
|
||||
context: Context = Global,
|
||||
context: Context,
|
||||
embedData: Boolean = true,
|
||||
fetchDataUrl: String? = null,
|
||||
fetchUpdatesUrl: String? = null,
|
||||
updatesUrl: String? = null,
|
||||
idPrefix: String? = null,
|
||||
collector: VisionCollector = mutableMapOf(),
|
||||
fragment: HtmlVisionFragment,
|
||||
): Map<Name, Vision> {
|
||||
val visionMap = HashMap<Name, Vision>()
|
||||
) {
|
||||
val consumer = object : VisionTagConsumer<Any?>(this@visionFragment, context, idPrefix) {
|
||||
|
||||
override fun <T> TagConsumer<T>.vision(name: Name?, buildOutput: VisionOutput.() -> Vision): T {
|
||||
//Avoid re-creating cached visions
|
||||
val actualName = name ?: NameToken(
|
||||
DEFAULT_VISION_NAME,
|
||||
buildOutput.hashCode().toUInt().toString()
|
||||
).asName()
|
||||
|
||||
val (output, vision) = collector.getOrPut(actualName) {
|
||||
val output = VisionOutput(context, actualName)
|
||||
val vision = output.buildOutput()
|
||||
output to vision
|
||||
}
|
||||
|
||||
return addVision(actualName, output.visionManager, vision, output.meta)
|
||||
}
|
||||
|
||||
override fun DIV.renderVision(manager: VisionManager, name: Name, vision: Vision, outputMeta: Meta) {
|
||||
visionMap[name] = vision
|
||||
// Toggle update mode
|
||||
|
||||
fetchUpdatesUrl?.let {
|
||||
updatesUrl?.let {
|
||||
attributes[OUTPUT_CONNECT_ATTRIBUTE] = it
|
||||
}
|
||||
|
||||
@ -59,22 +77,22 @@ public fun TagConsumer<*>.visionFragment(
|
||||
}
|
||||
|
||||
fragment(consumer)
|
||||
|
||||
return visionMap
|
||||
}
|
||||
|
||||
public fun FlowContent.visionFragment(
|
||||
context: Context = Global,
|
||||
context: Context,
|
||||
embedData: Boolean = true,
|
||||
fetchDataUrl: String? = null,
|
||||
flowDataUrl: String? = null,
|
||||
updatesUrl: String? = null,
|
||||
idPrefix: String? = null,
|
||||
visionCache: VisionCollector = mutableMapOf(),
|
||||
fragment: HtmlVisionFragment,
|
||||
): Map<Name, Vision> = consumer.visionFragment(
|
||||
): Unit = consumer.visionFragment(
|
||||
context,
|
||||
embedData,
|
||||
fetchDataUrl,
|
||||
flowDataUrl,
|
||||
updatesUrl,
|
||||
idPrefix,
|
||||
fragment
|
||||
visionCache,
|
||||
fragment = fragment
|
||||
)
|
@ -1,7 +1,7 @@
|
||||
package space.kscience.visionforge.html
|
||||
|
||||
import kotlinx.html.*
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.visionforge.VisionManager
|
||||
|
||||
/**
|
||||
* A structure representing a single page with Visions to be rendered.
|
||||
@ -9,7 +9,7 @@ import space.kscience.dataforge.context.Context
|
||||
* @param pageHeaders static headers for this page.
|
||||
*/
|
||||
public data class VisionPage(
|
||||
public val context: Context,
|
||||
public val visionManager: VisionManager,
|
||||
public val pageHeaders: Map<String, HtmlFragment> = emptyMap(),
|
||||
public val content: HtmlVisionFragment,
|
||||
) {
|
||||
|
@ -7,7 +7,6 @@ import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.MetaSerializer
|
||||
import space.kscience.dataforge.meta.MutableMeta
|
||||
import space.kscience.dataforge.meta.isEmpty
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.NameToken
|
||||
import space.kscience.dataforge.names.asName
|
||||
@ -25,9 +24,8 @@ public annotation class VisionDSL
|
||||
/**
|
||||
* A placeholder object to attach inline vision builders.
|
||||
*/
|
||||
@DFExperimental
|
||||
@VisionDSL
|
||||
public class VisionOutput @PublishedApi internal constructor(public val context: Context, public val name: Name?) {
|
||||
public class VisionOutput @PublishedApi internal constructor(public val context: Context, public val name: Name) {
|
||||
public var meta: Meta = Meta.EMPTY
|
||||
|
||||
private val requirements: MutableSet<PluginFactory<*>> = HashSet()
|
||||
@ -36,8 +34,8 @@ public class VisionOutput @PublishedApi internal constructor(public val context:
|
||||
requirements.add(factory)
|
||||
}
|
||||
|
||||
internal fun buildVisionManager(): VisionManager =
|
||||
if (requirements.all { req -> context.plugins.find(true) { it.tag == req.tag } != null }) {
|
||||
public val visionManager: VisionManager
|
||||
get() = if (requirements.all { req -> context.plugins.find(true) { it.tag == req.tag } != null }) {
|
||||
context.visionManager
|
||||
} else {
|
||||
val newContext = context.buildContext(NameToken(DEFAULT_VISION_NAME, name.toString()).asName()) {
|
||||
@ -56,7 +54,6 @@ public class VisionOutput @PublishedApi internal constructor(public val context:
|
||||
* Modified [TagConsumer] that allows rendering output fragments and visions in them
|
||||
*/
|
||||
@VisionDSL
|
||||
@OptIn(DFExperimental::class)
|
||||
public abstract class VisionTagConsumer<R>(
|
||||
private val root: TagConsumer<R>,
|
||||
public val context: Context,
|
||||
@ -78,12 +75,14 @@ public abstract class VisionTagConsumer<R>(
|
||||
* Create a placeholder for a vision output with optional [Vision] in it
|
||||
* TODO with multi-receivers could be replaced by [VisionTagConsumer, TagConsumer] extension
|
||||
*/
|
||||
private fun <T> TagConsumer<T>.vision(
|
||||
protected fun <T> TagConsumer<T>.addVision(
|
||||
name: Name,
|
||||
manager: VisionManager,
|
||||
vision: Vision,
|
||||
vision: Vision?,
|
||||
outputMeta: Meta = Meta.EMPTY,
|
||||
): T = div {
|
||||
): T = if (vision == null) div {
|
||||
+"Empty Vision output"
|
||||
} else div {
|
||||
id = resolveId(name)
|
||||
classes = setOf(OUTPUT_CLASS)
|
||||
if (vision.parent == null) {
|
||||
@ -106,26 +105,35 @@ public abstract class VisionTagConsumer<R>(
|
||||
* Insert a vision in this HTML.
|
||||
* TODO replace by multi-receiver
|
||||
*/
|
||||
@OptIn(DFExperimental::class)
|
||||
public fun <T> TagConsumer<T>.vision(
|
||||
@VisionDSL
|
||||
public open fun <T> TagConsumer<T>.vision(
|
||||
name: Name? = null,
|
||||
@OptIn(DFExperimental::class) visionProvider: VisionOutput.() -> Vision,
|
||||
buildOutput: VisionOutput.() -> Vision,
|
||||
): T {
|
||||
val output = VisionOutput(context, name)
|
||||
val vision = output.visionProvider()
|
||||
val actualName = name ?: NameToken(DEFAULT_VISION_NAME, vision.hashCode().toUInt().toString()).asName()
|
||||
return vision(actualName, output.buildVisionManager(), vision, output.meta)
|
||||
val actualName = name ?: NameToken(DEFAULT_VISION_NAME, buildOutput.hashCode().toUInt().toString()).asName()
|
||||
val output = VisionOutput(context, actualName)
|
||||
val vision = output.buildOutput()
|
||||
return addVision(actualName, output.visionManager, vision, output.meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO to be replaced by multi-receiver
|
||||
*/
|
||||
@OptIn(DFExperimental::class)
|
||||
@VisionDSL
|
||||
public fun <T> TagConsumer<T>.vision(
|
||||
name: String?,
|
||||
@OptIn(DFExperimental::class) visionProvider: VisionOutput.() -> Vision,
|
||||
): T = vision(name?.parseAsName(), visionProvider)
|
||||
buildOutput: VisionOutput.() -> Vision,
|
||||
): T = vision(name?.parseAsName(), buildOutput)
|
||||
|
||||
@VisionDSL
|
||||
public open fun <T> TagConsumer<T>.vision(
|
||||
vision: Vision,
|
||||
name: Name? = null,
|
||||
outputMeta: Meta = Meta.EMPTY,
|
||||
) {
|
||||
val actualName = name ?: NameToken(DEFAULT_VISION_NAME, vision.hashCode().toUInt().toString()).asName()
|
||||
addVision(actualName, context.visionManager, vision, outputMeta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the resulting object produced by [TagConsumer]
|
||||
|
@ -5,13 +5,13 @@ import space.kscience.dataforge.context.ContextAware
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.MetaSerializer
|
||||
import space.kscience.dataforge.meta.isEmpty
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.NameToken
|
||||
import space.kscience.dataforge.names.asName
|
||||
import space.kscience.dataforge.names.parseAsName
|
||||
import space.kscience.visionforge.Vision
|
||||
import space.kscience.visionforge.VisionManager
|
||||
import space.kscience.visionforge.html.VisionTagConsumer.Companion.DEFAULT_VISION_NAME
|
||||
import space.kscience.visionforge.setAsRoot
|
||||
import space.kscience.visionforge.visionManager
|
||||
|
||||
@ -34,11 +34,13 @@ public interface HtmlVisionContext : ContextAware {
|
||||
|
||||
public typealias HtmlVisionContextFragment = context(HtmlVisionContext) TagConsumer<*>.() -> Unit
|
||||
|
||||
context(HtmlVisionContext) public fun HtmlVisionFragment(
|
||||
content: TagConsumer<*>.() -> Unit
|
||||
context(HtmlVisionContext)
|
||||
public fun HtmlVisionFragment(
|
||||
content: TagConsumer<*>.() -> Unit,
|
||||
): HtmlVisionFragment = content
|
||||
|
||||
context(HtmlVisionContext) private fun <T> TagConsumer<T>.vision(
|
||||
context(HtmlVisionContext)
|
||||
private fun <T> TagConsumer<T>.vision(
|
||||
visionManager: VisionManager,
|
||||
name: Name,
|
||||
vision: Vision,
|
||||
@ -61,7 +63,7 @@ context(HtmlVisionContext) private fun <T> TagConsumer<T>.vision(
|
||||
}
|
||||
|
||||
context(HtmlVisionContext)
|
||||
private fun <T> TagConsumer<T>.vision(
|
||||
private fun <T> TagConsumer<T>.vision(
|
||||
name: Name,
|
||||
vision: Vision,
|
||||
outputMeta: Meta = Meta.EMPTY,
|
||||
@ -71,26 +73,23 @@ context(HtmlVisionContext)
|
||||
* Insert a vision in this HTML.
|
||||
*/
|
||||
context(HtmlVisionContext)
|
||||
@DFExperimental
|
||||
@VisionDSL
|
||||
public fun <T> TagConsumer<T>.vision(
|
||||
@VisionDSL
|
||||
public fun <T> TagConsumer<T>.vision(
|
||||
name: Name? = null,
|
||||
visionProvider: VisionOutput.() -> Vision,
|
||||
): T {
|
||||
val output = VisionOutput(context, name)
|
||||
val actualName = name ?: NameToken(DEFAULT_VISION_NAME, visionProvider.hashCode().toUInt().toString()).asName()
|
||||
val output = VisionOutput(context, actualName)
|
||||
val vision = output.visionProvider()
|
||||
val actualName =
|
||||
name ?: NameToken(VisionTagConsumer.DEFAULT_VISION_NAME, vision.hashCode().toUInt().toString()).asName()
|
||||
return vision(output.buildVisionManager(), actualName, vision, output.meta)
|
||||
return vision(output.visionManager, actualName, vision, output.meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a vision in this HTML.
|
||||
*/
|
||||
context(HtmlVisionContext)
|
||||
@DFExperimental
|
||||
@VisionDSL
|
||||
public fun <T> TagConsumer<T>.vision(
|
||||
@VisionDSL
|
||||
public fun <T> TagConsumer<T>.vision(
|
||||
name: String?,
|
||||
visionProvider: VisionOutput.() -> Vision,
|
||||
): T = vision(name?.parseAsName(), visionProvider)
|
@ -4,6 +4,7 @@ import kotlinx.html.body
|
||||
import kotlinx.html.head
|
||||
import kotlinx.html.meta
|
||||
import kotlinx.html.stream.createHTML
|
||||
import space.kscience.dataforge.context.Global
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.visionforge.html.HtmlFragment
|
||||
import space.kscience.visionforge.html.VisionPage
|
||||
@ -86,7 +87,7 @@ public fun VisionPage.makeFile(
|
||||
}
|
||||
}
|
||||
body {
|
||||
visionFragment(context, fragment = content)
|
||||
visionFragment(Global, fragment = content)
|
||||
}
|
||||
}.finalize()
|
||||
|
||||
|
@ -2,39 +2,34 @@ package space.kscience.visionforge.server
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.ApplicationEngine
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.html.respondHtml
|
||||
import io.ktor.server.http.content.resources
|
||||
import io.ktor.server.http.content.static
|
||||
import io.ktor.server.plugins.cors.routing.CORS
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.html.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.plugins.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.getOrFail
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.util.pipeline.Pipeline
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.server.util.*
|
||||
import io.ktor.server.websocket.*
|
||||
import io.ktor.util.pipeline.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.stream.createHTML
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
import space.kscience.dataforge.meta.*
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.visionforge.Vision
|
||||
import space.kscience.visionforge.VisionChange
|
||||
import space.kscience.visionforge.VisionManager
|
||||
import space.kscience.visionforge.flowChanges
|
||||
import space.kscience.visionforge.html.HtmlFragment
|
||||
import space.kscience.visionforge.html.HtmlVisionFragment
|
||||
import space.kscience.visionforge.html.VisionPage
|
||||
import space.kscience.visionforge.html.visionFragment
|
||||
import space.kscience.visionforge.server.VisionServer.Companion.DEFAULT_PAGE
|
||||
import space.kscience.visionforge.html.*
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@ -57,210 +52,20 @@ public enum class DataServeMode {
|
||||
UPDATE
|
||||
}
|
||||
|
||||
/**
|
||||
* A ktor plugin container with given [routing]
|
||||
* @param serverUrl a server url including root route
|
||||
*/
|
||||
public class VisionServer internal constructor(
|
||||
private val visionManager: VisionManager,
|
||||
private val serverUrl: Url,
|
||||
private val root: Route,
|
||||
) : Configurable {
|
||||
public class VisionRouteConfiguration(
|
||||
public val visionManager: VisionManager,
|
||||
override val meta: ObservableMutableMeta = MutableMeta(),
|
||||
) : Configurable, ContextAware {
|
||||
|
||||
public val application: Application get() = root.application
|
||||
|
||||
override val meta: ObservableMutableMeta = MutableMeta()
|
||||
override val context: Context get() = visionManager.context
|
||||
|
||||
/**
|
||||
* Update minimal interval between updates in milliseconds (if there are no updates, push will not happen
|
||||
*/
|
||||
public var updateInterval: Long by meta.long(300, key = UPDATE_INTERVAL_KEY)
|
||||
|
||||
/**
|
||||
* Cache page fragments. If false, pages will be reconstructed on each call. Default: `true`
|
||||
*/
|
||||
public var cacheFragments: Boolean by meta.boolean(true)
|
||||
|
||||
public var dataMode: DataServeMode by meta.enum(DataServeMode.UPDATE)
|
||||
|
||||
private val serverHeaders: MutableMap<String, HtmlFragment> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Set up a default header that is automatically added to all pages on this server
|
||||
*/
|
||||
public fun header(key: String, block: HtmlFragment) {
|
||||
serverHeaders[key] = block
|
||||
}
|
||||
|
||||
private fun HTML.visionPage(
|
||||
pagePath: String,
|
||||
headers: Map<String, HtmlFragment>,
|
||||
visionFragment: HtmlVisionFragment,
|
||||
): Map<Name, Vision> {
|
||||
var visionMap: Map<Name, Vision>? = null
|
||||
|
||||
head {
|
||||
meta {
|
||||
charset = "utf-8"
|
||||
}
|
||||
(serverHeaders + headers).values.forEach {
|
||||
consumer.it()
|
||||
}
|
||||
}
|
||||
body {
|
||||
//Load the fragment and remember all loaded visions
|
||||
visionMap = visionFragment(
|
||||
context = visionManager.context,
|
||||
embedData = dataMode == DataServeMode.EMBED,
|
||||
fetchDataUrl = if (dataMode != DataServeMode.EMBED) "$serverUrl$pagePath/data" else null,
|
||||
flowDataUrl = if (dataMode == DataServeMode.UPDATE) "$serverUrl$pagePath/ws" else null,
|
||||
fragment = visionFragment
|
||||
)
|
||||
}
|
||||
|
||||
return visionMap!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Server a map of visions without providing explicit html page for them
|
||||
*/
|
||||
private fun serveVisions(route: Route, visions: Map<Name, Vision>): Unit = route {
|
||||
application.log.info("Serving visions $visions at $route")
|
||||
|
||||
//Update websocket
|
||||
webSocket("ws") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
application.log.debug("Opened server socket for $name")
|
||||
val vision: Vision = visions[Name.parse(name)] ?: error("Plot with id='$name' not registered")
|
||||
|
||||
launch {
|
||||
incoming.consumeEach {
|
||||
val data = it.data.decodeToString()
|
||||
application.log.debug("Received update: \n$data")
|
||||
val change = visionManager.jsonFormat.decodeFromString(
|
||||
VisionChange.serializer(), data
|
||||
)
|
||||
vision.update(change)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
withContext(visionManager.context.coroutineContext) {
|
||||
vision.flowChanges(updateInterval.milliseconds).onEach { update ->
|
||||
val json = visionManager.jsonFormat.encodeToString(
|
||||
VisionChange.serializer(),
|
||||
update
|
||||
)
|
||||
application.log.debug("Sending update: \n$json")
|
||||
outgoing.send(Frame.Text(json))
|
||||
}.collect()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
application.log.info("WebSocket update channel for $name is closed with exception: $t")
|
||||
}
|
||||
}
|
||||
//Plots in their json representation
|
||||
get("data") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
|
||||
val vision: Vision? = visions[Name.parse(name)]
|
||||
if (vision == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found")
|
||||
} else {
|
||||
call.respondText(
|
||||
visionManager.encodeToString(vision),
|
||||
contentType = ContentType.Application.Json,
|
||||
status = HttpStatusCode.OK
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serve visions in a given [route] without providing a page template
|
||||
*/
|
||||
public fun serveVisions(route: String, visions: Map<Name, Vision>) {
|
||||
root.route(route) {
|
||||
serveVisions(this, visions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a fragment to string and serve visions from it
|
||||
*/
|
||||
public fun serveVisionsFromFragment(
|
||||
consumer: TagConsumer<*>,
|
||||
route: String,
|
||||
fragment: HtmlVisionFragment,
|
||||
): Unit {
|
||||
val visions = consumer.visionFragment(
|
||||
visionManager.context,
|
||||
embedData = true,
|
||||
fetchUpdatesUrl = "$serverUrl$route/ws",
|
||||
fragment = fragment
|
||||
)
|
||||
|
||||
serveVisions(route, visions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a page, potentially containing any number of visions at a given [route] with given [header].
|
||||
*/
|
||||
public fun page(
|
||||
route: String = DEFAULT_PAGE,
|
||||
headers: Map<String, HtmlFragment>,
|
||||
visionFragment: HtmlVisionFragment,
|
||||
) {
|
||||
val visions = HashMap<Name, Vision>()
|
||||
|
||||
val cachedHtml: String? = if (cacheFragments) {
|
||||
//Create and cache page html and map of visions
|
||||
createHTML(true).html {
|
||||
visions.putAll(visionPage(route, headers, visionFragment))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
root.route(route) {
|
||||
serveVisions(this, visions)
|
||||
//filled pages
|
||||
get {
|
||||
if (cachedHtml == null) {
|
||||
//re-create html and vision list on each call
|
||||
call.respondHtml {
|
||||
visions.clear()
|
||||
visions.putAll(visionPage(route, headers, visionFragment))
|
||||
}
|
||||
} else {
|
||||
//Use cached html
|
||||
call.respondText(cachedHtml, ContentType.Text.Html.withCharset(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun page(
|
||||
vararg headers: HtmlFragment,
|
||||
route: String = DEFAULT_PAGE,
|
||||
title: String = "VisionForge server page '$route'",
|
||||
visionFragment: HtmlVisionFragment,
|
||||
) {
|
||||
page(
|
||||
route,
|
||||
mapOf("title" to VisionPage.title(title)) + headers.associateBy { it.hashCode().toString() },
|
||||
visionFragment
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render given [VisionPage] at server
|
||||
*/
|
||||
public fun page(route: String, page: VisionPage) {
|
||||
page(route = route, headers = page.pageHeaders, visionFragment = page.content)
|
||||
}
|
||||
|
||||
public companion object {
|
||||
public const val DEFAULT_PORT: Int = 7777
|
||||
public const val DEFAULT_PAGE: String = "/"
|
||||
@ -268,50 +73,168 @@ public class VisionServer internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serve visions in a given [route] without providing a page template.
|
||||
* [visions] could be changed during the service.
|
||||
*/
|
||||
public fun Route.serveVisionData(
|
||||
configuration: VisionRouteConfiguration,
|
||||
resolveVision: (Name) -> Vision?,
|
||||
) {
|
||||
application.log.info("Serving visions at ${this@serveVisionData}")
|
||||
|
||||
//Update websocket
|
||||
webSocket("ws") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
application.log.debug("Opened server socket for $name")
|
||||
val vision: Vision = resolveVision(Name.parse(name)) ?: error("Plot with id='$name' not registered")
|
||||
|
||||
launch {
|
||||
incoming.consumeEach {
|
||||
val data = it.data.decodeToString()
|
||||
application.log.debug("Received update: \n$data")
|
||||
val change = configuration.visionManager.jsonFormat.decodeFromString(
|
||||
VisionChange.serializer(), data
|
||||
)
|
||||
vision.update(change)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
withContext(configuration.context.coroutineContext) {
|
||||
vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update ->
|
||||
val json = configuration.visionManager.jsonFormat.encodeToString(
|
||||
VisionChange.serializer(),
|
||||
update
|
||||
)
|
||||
application.log.debug("Sending update: \n$json")
|
||||
outgoing.send(Frame.Text(json))
|
||||
}.collect()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
this.application.log.info("WebSocket update channel for $name is closed with exception: $t")
|
||||
}
|
||||
}
|
||||
//Plots in their json representation
|
||||
get("data") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
|
||||
val vision: Vision? = resolveVision(Name.parse(name))
|
||||
if (vision == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found")
|
||||
} else {
|
||||
call.respondText(
|
||||
configuration.visionManager.encodeToString(vision),
|
||||
contentType = ContentType.Application.Json,
|
||||
status = HttpStatusCode.OK
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun Route.serveVisionData(
|
||||
configuration: VisionRouteConfiguration,
|
||||
cache: VisionCollector,
|
||||
): Unit = serveVisionData(configuration) { cache[it]?.second }
|
||||
//
|
||||
///**
|
||||
// * Compile a fragment to string and serve visions from it
|
||||
// */
|
||||
//public fun Route.serveVisionsFromFragment(
|
||||
// consumer: TagConsumer<*>,
|
||||
// sererPageUrl: Url,
|
||||
// visionManager: VisionManager,
|
||||
// fragment: HtmlVisionFragment,
|
||||
//): Unit {
|
||||
// val visions = consumer.visionFragment(
|
||||
// visionManager.context,
|
||||
// embedData = true,
|
||||
// fetchUpdatesUrl = "$serverUrl$route/ws",
|
||||
// fragment = fragment
|
||||
// )
|
||||
//
|
||||
// serveVisionData(visionManager, visions)
|
||||
//}
|
||||
|
||||
|
||||
/**
|
||||
* Serve a page, potentially containing any number of visions at a given [route] with given [header].
|
||||
*/
|
||||
public fun Route.visionPage(
|
||||
configuration: VisionRouteConfiguration,
|
||||
headers: Collection<HtmlFragment>,
|
||||
visionFragment: HtmlVisionFragment,
|
||||
) {
|
||||
application.require(WebSockets)
|
||||
require(CORS) {
|
||||
anyHost()
|
||||
}
|
||||
|
||||
val visionCache: VisionCollector = mutableMapOf()
|
||||
serveVisionData(configuration, visionCache)
|
||||
|
||||
//filled pages
|
||||
get {
|
||||
//re-create html and vision list on each call
|
||||
call.respondHtml {
|
||||
val callbackUrl = call.url()
|
||||
head {
|
||||
meta {
|
||||
charset = "utf-8"
|
||||
}
|
||||
headers.forEach { header ->
|
||||
consumer.header()
|
||||
}
|
||||
}
|
||||
body {
|
||||
//Load the fragment and remember all loaded visions
|
||||
visionFragment(
|
||||
context = configuration.context,
|
||||
embedData = configuration.dataMode == DataServeMode.EMBED,
|
||||
fetchDataUrl = if (configuration.dataMode != DataServeMode.EMBED) {
|
||||
URLBuilder(callbackUrl).apply {
|
||||
pathSegments = pathSegments + "data"
|
||||
}.buildString()
|
||||
} else null,
|
||||
updatesUrl = if (configuration.dataMode == DataServeMode.UPDATE) {
|
||||
URLBuilder(callbackUrl).apply {
|
||||
protocol = URLProtocol.WS
|
||||
pathSegments = pathSegments + "ws"
|
||||
}.buildString()
|
||||
} else null,
|
||||
visionCache = visionCache,
|
||||
fragment = visionFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public fun Route.visionPage(
|
||||
visionManager: VisionManager,
|
||||
vararg headers: HtmlFragment,
|
||||
configurationBuilder: VisionRouteConfiguration.() -> Unit = {},
|
||||
visionFragment: HtmlVisionFragment,
|
||||
) {
|
||||
val configuration = VisionRouteConfiguration(visionManager).apply(configurationBuilder)
|
||||
visionPage(configuration, listOf(*headers), visionFragment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render given [VisionPage] at server
|
||||
*/
|
||||
public fun Route.visionPage(page: VisionPage, configurationBuilder: VisionRouteConfiguration.() -> Unit = {}) {
|
||||
val configuration = VisionRouteConfiguration(page.visionManager).apply(configurationBuilder)
|
||||
visionPage(configuration, page.pageHeaders.values, visionFragment = page.content)
|
||||
}
|
||||
|
||||
public fun <P : Pipeline<*, ApplicationCall>, B : Any, F : Any> P.require(
|
||||
plugin: Plugin<P, B, F>,
|
||||
configure: B.() -> Unit = {},
|
||||
): F = pluginOrNull(plugin) ?: install(plugin, configure)
|
||||
|
||||
/**
|
||||
* Attach VisionForge server application to given server
|
||||
*/
|
||||
public fun Application.visionServer(
|
||||
visionManager: VisionManager,
|
||||
webServerUrl: Url,
|
||||
path: String = DEFAULT_PAGE,
|
||||
): VisionServer {
|
||||
require(WebSockets)
|
||||
require(CORS) {
|
||||
anyHost()
|
||||
}
|
||||
|
||||
// if (pluginOrNull(CallLogging) == null) {
|
||||
// install(CallLogging)
|
||||
// }
|
||||
|
||||
val serverRoute = require(Routing).createRouteFromPath(path)
|
||||
|
||||
serverRoute {
|
||||
static {
|
||||
resources()
|
||||
}
|
||||
}
|
||||
|
||||
return VisionServer(visionManager, URLBuilder(webServerUrl).apply { encodedPath = path }.build(), serverRoute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stand-alone VisionForge server at given host/port
|
||||
*/
|
||||
public fun VisionManager.serve(
|
||||
host: String = "localhost",
|
||||
port: Int = VisionServer.DEFAULT_PORT,
|
||||
block: VisionServer.() -> Unit,
|
||||
): ApplicationEngine = context.embeddedServer(CIO, port, host) {
|
||||
val url = URLBuilder(host = host, port = port).build()
|
||||
visionServer(this@serve, url).apply(block)
|
||||
}.start()
|
||||
|
||||
/**
|
||||
* Connect to a given Ktor server using browser
|
||||
|
@ -33,7 +33,7 @@ public class Solids(meta: Meta) : VisionPlugin(meta), MutableVisionContainer<Sol
|
||||
override val type: KClass<out Solids> = Solids::class
|
||||
|
||||
public val default: Solids by lazy {
|
||||
Context("@Solids"){
|
||||
Context("@Solids") {
|
||||
plugin(Solids)
|
||||
}.fetch(Solids)
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import space.kscience.dataforge.context.Global
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.visionforge.html.*
|
||||
import space.kscience.visionforge.makeFile
|
||||
import space.kscience.visionforge.visionManager
|
||||
import java.awt.Desktop
|
||||
import java.nio.file.Path
|
||||
|
||||
@ -19,7 +20,7 @@ public fun makeThreeJsFile(
|
||||
show: Boolean = true,
|
||||
content: HtmlVisionFragment,
|
||||
): Unit {
|
||||
val actualPath = VisionPage(Global, content = content).makeFile(path) { actualPath ->
|
||||
val actualPath = VisionPage(Global.visionManager, content = content).makeFile(path) { actualPath ->
|
||||
mapOf(
|
||||
"title" to VisionPage.title(title),
|
||||
"threeJs" to VisionPage.importScriptHeader("js/visionforge-three.js", resourceLocation, actualPath)
|
||||
|
Loading…
Reference in New Issue
Block a user