Remove websockets

This commit is contained in:
Lars Westermann 2019-06-11 10:36:12 +02:00
parent 8f8242a97a
commit 7b7a9b0fc2
Signed by: lars.westermann
GPG key ID: 9D417FA5BB9D5E1D
17 changed files with 196 additions and 123 deletions

View file

@ -83,7 +83,6 @@ kotlin {
implementation "io.ktor:ktor-server-netty:$ktor_version" implementation "io.ktor:ktor-server-netty:$ktor_version"
implementation "io.ktor:ktor-auth:$ktor_version" implementation "io.ktor:ktor-auth:$ktor_version"
implementation "io.ktor:ktor-server-sessions:$ktor_version" implementation "io.ktor:ktor-server-sessions:$ktor_version"
implementation "io.ktor:ktor-websockets:$ktor_version"
implementation "io.ktor:ktor-jackson:$ktor_version" implementation "io.ktor:ktor-jackson:$ktor_version"
implementation 'org.jetbrains:kotlin-css-jvm:1.0.0-pre.70-kotlin-1.3.21' implementation 'org.jetbrains:kotlin-css-jvm:1.0.0-pre.70-kotlin-1.3.21'

View file

@ -1,30 +1,24 @@
package de.kif.common package de.kif.common
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
@Serializable
data class MessageBox(
val timestamp: Long,
val valid: Boolean,
val messages: List<Message>
)
@Serializable @Serializable
data class Message( data class Message(
val type: MessageType, val type: MessageType,
val repository: RepositoryType, val repository: RepositoryType,
val id: Long val id: Long
) { )
fun stringify(): String {
return json.stringify(serializer(), this)
}
companion object {
private val jsonContext = SerializersModule {}
val json = Json(context = jsonContext)
fun parse(data: String): Message {
return json.parse(serializer(), data)
}
}
}
enum class MessageType { enum class MessageType {
CREATE, UPDATE, DELETE CREATE, UPDATE, DELETE
@ -33,3 +27,12 @@ enum class MessageType {
enum class RepositoryType { enum class RepositoryType {
ROOM, SCHEDULE, TRACK, USER, WORK_GROUP, POST ROOM, SCHEDULE, TRACK, USER, WORK_GROUP, POST
} }
object Serialization {
private val jsonContext = SerializersModule {}
val json = Json(context = jsonContext)
fun <T> stringify(serializer: SerializationStrategy<T>, obj: T) = json.stringify(serializer, obj)
fun <T> parse(serializer: DeserializationStrategy<T>, str: String) = json.parse(serializer, str)
}

View file

@ -10,12 +10,12 @@ data class SearchElement(
) { ) {
fun stringify(): String { fun stringify(): String {
return Message.json.stringify(serializer(), this) return Serialization.stringify(serializer(), this)
} }
companion object { companion object {
fun parse(data: String): SearchElement { fun parse(data: String): SearchElement {
return Message.json.parse(serializer(), data) return Serialization.parse(serializer(), data)
} }
} }
} }

View file

@ -1,36 +1,42 @@
package de.kif.frontend package de.kif.frontend
import de.kif.common.Message import de.kif.common.MessageBox
import de.kif.common.MessageType import de.kif.common.MessageType
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.frontend.repository.* import de.kif.frontend.repository.*
import de.westermann.kwebview.async import de.westermann.kwebview.clearInterval
import org.w3c.dom.MessageEvent import de.westermann.kwebview.createHtmlView
import org.w3c.dom.WebSocket import de.westermann.kwebview.interval
import org.w3c.dom.events.Event import kotlinx.serialization.DynamicObjectParser
import org.w3c.dom.get
import org.w3c.xhr.XMLHttpRequest
import kotlin.browser.document
import kotlin.browser.window import kotlin.browser.window
class WebSocketClient() { class WebSocketClient {
private val prefix = js("prefix") private val prefix = js("prefix")
private val url = "$prefix/api/updates"
private val body = document.body ?: createHtmlView()
private val parser = DynamicObjectParser()
private val useSsl = "https" in window.location.protocol private var timestamp = body.dataset["timestamp"]?.toLongOrNull() ?: 0L
private val wsProtocol = if (useSsl) "wss" else "ws" private var intervalId: Int? = null
private val url = "$wsProtocol://${window.location.host}$prefix/websocket"
private lateinit var ws: WebSocket private fun reload() {
private var reconnect = false val id = intervalId ?: return
clearInterval(id)
intervalId = null
@Suppress("UNUSED_PARAMETER")
private fun onOpen(event: Event) {
console.log("Connected!")
if (reconnect) {
window.location.reload() window.location.reload()
} }
}
private fun onMessage(messageEvent: MessageEvent) { private fun onMessage(messageBox: MessageBox) {
val message = Message.parse(messageEvent.data?.toString() ?: "") body.classList.remove("offline")
if (messageBox.valid) {
timestamp = messageBox.timestamp
for (message in messageBox.messages) {
for (handler in messageHandlers) { for (handler in messageHandlers) {
if (handler.repository == message.repository) { if (handler.repository == message.repository) {
when (message.type) { when (message.type) {
@ -41,28 +47,46 @@ class WebSocketClient() {
} }
} }
} }
} else {
@Suppress("UNUSED_PARAMETER") reload()
private fun onClose(event: Event) {
console.log("Disconnected!")
reconnect = true
async(1000) {
connect()
} }
} }
@Suppress("UNUSED_PARAMETER") private fun onError(code: Int) {
private fun onError(event: Event) { if (!body.classList.contains("offline")) {
console.log("An error occurred!") console.log("Offline reason: $code")
}
body.classList.add("offline")
} }
private fun connect() { private fun request() {
ws = WebSocket(url) val xmlHttpRequest = XMLHttpRequest()
ws.onopen = this::onOpen xmlHttpRequest.onreadystatechange = {
ws.onmessage = this::onMessage try {
ws.onclose = this::onClose if (xmlHttpRequest.readyState == 4.toShort()) {
ws.onerror = this::onError if (xmlHttpRequest.status == 200.toShort() || xmlHttpRequest.status == 304.toShort()) {
val json = JSON.parse<JsonResponse>(xmlHttpRequest.responseText)
if (json.OK) {
val message = parser.parse(json.data, MessageBox.serializer())
onMessage(message)
} else {
onError(-1)
}
} else {
onError(xmlHttpRequest.status.toInt())
}
}
} catch (e: Exception) {
console.error(e)
onError(-2)
}
}
xmlHttpRequest.open("GET", "$url?timestamp=$timestamp", true)
xmlHttpRequest.overrideMimeType("application/json")
xmlHttpRequest.send()
} }
private val messageHandlers: List<MessageHandler> = listOf( private val messageHandlers: List<MessageHandler> = listOf(
@ -75,7 +99,9 @@ class WebSocketClient() {
) )
init { init {
connect() intervalId = interval(500) {
request()
}
} }
} }

View file

@ -8,10 +8,15 @@ import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.js.Promise import kotlin.js.Promise
interface JsonResponse {
val OK: Boolean
val data: dynamic
}
suspend fun repositoryGet( suspend fun repositoryGet(
url: String url: String
): dynamic { ): dynamic {
val promise = Promise<dynamic> { resolve, reject -> val promise = Promise<JsonResponse> { resolve, reject ->
val xhttp = XMLHttpRequest() val xhttp = XMLHttpRequest()
xhttp.onreadystatechange = { xhttp.onreadystatechange = {
@ -47,7 +52,7 @@ suspend fun repositoryPost(
url: String, url: String,
data: String? = null data: String? = null
): dynamic { ): dynamic {
val promise = Promise<dynamic> { resolve, reject -> val promise = Promise<JsonResponse> { resolve, reject ->
val xhttp = XMLHttpRequest() val xhttp = XMLHttpRequest()
xhttp.onreadystatechange = { xhttp.onreadystatechange = {

View file

@ -3,6 +3,7 @@ package de.kif.frontend.repository
import de.kif.common.Message import de.kif.common.Message
import de.kif.common.Repository import de.kif.common.Repository
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.Post import de.kif.common.model.Post
import de.kif.frontend.MessageHandler import de.kif.frontend.MessageHandler
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -25,13 +26,13 @@ object PostRepository : Repository<Post> {
} }
override suspend fun create(model: Post): Long { override suspend fun create(model: Post): Long {
return repositoryPost("$prefix/api/posts", Message.json.stringify(Post.serializer(), model)) return repositoryPost("$prefix/api/posts", Serialization.stringify(Post.serializer(), model))
?: throw IllegalStateException("Cannot create model!") ?: throw IllegalStateException("Cannot create model!")
} }
override suspend fun update(model: Post) { override suspend fun update(model: Post) {
if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") if (model.id == null) throw IllegalStateException("Cannot update model which was not created!")
repositoryPost("$prefix/api/post/${model.id}", Message.json.stringify(Post.serializer(), model)) repositoryPost("$prefix/api/post/${model.id}", Serialization.stringify(Post.serializer(), model))
} }
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {

View file

@ -3,6 +3,7 @@ package de.kif.frontend.repository
import de.kif.common.Message import de.kif.common.Message
import de.kif.common.Repository import de.kif.common.Repository
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.Room import de.kif.common.model.Room
import de.kif.frontend.MessageHandler import de.kif.frontend.MessageHandler
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -25,13 +26,13 @@ object RoomRepository : Repository<Room> {
} }
override suspend fun create(model: Room): Long { override suspend fun create(model: Room): Long {
return repositoryPost("$prefix/api/rooms", Message.json.stringify(Room.serializer(), model)) return repositoryPost("$prefix/api/rooms", Serialization.stringify(Room.serializer(), model))
?: throw IllegalStateException("Cannot create model!") ?: throw IllegalStateException("Cannot create model!")
} }
override suspend fun update(model: Room) { override suspend fun update(model: Room) {
if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") if (model.id == null) throw IllegalStateException("Cannot update model which was not created!")
repositoryPost("$prefix/api/room/${model.id}", Message.json.stringify(Room.serializer(), model)) repositoryPost("$prefix/api/room/${model.id}", Serialization.stringify(Room.serializer(), model))
} }
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {

View file

@ -1,9 +1,6 @@
package de.kif.frontend.repository package de.kif.frontend.repository
import de.kif.common.ConstraintMap import de.kif.common.*
import de.kif.common.Message
import de.kif.common.Repository
import de.kif.common.RepositoryType
import de.kif.common.model.Schedule import de.kif.common.model.Schedule
import de.kif.frontend.MessageHandler import de.kif.frontend.MessageHandler
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -31,13 +28,13 @@ object ScheduleRepository : Repository<Schedule> {
} }
override suspend fun create(model: Schedule): Long { override suspend fun create(model: Schedule): Long {
return repositoryPost("$prefix/api/schedules", Message.json.stringify(Schedule.serializer(), model)) return repositoryPost("$prefix/api/schedules", Serialization.stringify(Schedule.serializer(), model))
?: throw IllegalStateException("Cannot create model!") ?: throw IllegalStateException("Cannot create model!")
} }
override suspend fun update(model: Schedule) { override suspend fun update(model: Schedule) {
if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") if (model.id == null) throw IllegalStateException("Cannot update model which was not created!")
repositoryPost("$prefix/api/schedule/${model.id}", Message.json.stringify(Schedule.serializer(), model)) repositoryPost("$prefix/api/schedule/${model.id}", Serialization.stringify(Schedule.serializer(), model))
} }
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {

View file

@ -3,6 +3,7 @@ package de.kif.frontend.repository
import de.kif.common.Message import de.kif.common.Message
import de.kif.common.Repository import de.kif.common.Repository
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.Track import de.kif.common.model.Track
import de.kif.frontend.MessageHandler import de.kif.frontend.MessageHandler
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -25,13 +26,13 @@ object TrackRepository : Repository<Track> {
} }
override suspend fun create(model: Track): Long { override suspend fun create(model: Track): Long {
return repositoryPost("$prefix/api/tracks", Message.json.stringify(Track.serializer(), model)) return repositoryPost("$prefix/api/tracks", Serialization.stringify(Track.serializer(), model))
?: throw IllegalStateException("Cannot create model!") ?: throw IllegalStateException("Cannot create model!")
} }
override suspend fun update(model: Track) { override suspend fun update(model: Track) {
if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") if (model.id == null) throw IllegalStateException("Cannot update model which was not created!")
repositoryPost("$prefix/api/track/${model.id}", Message.json.stringify(Track.serializer(), model)) repositoryPost("$prefix/api/track/${model.id}", Serialization.stringify(Track.serializer(), model))
} }
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {

View file

@ -3,6 +3,7 @@ package de.kif.frontend.repository
import de.kif.common.Message import de.kif.common.Message
import de.kif.common.Repository import de.kif.common.Repository
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.User import de.kif.common.model.User
import de.kif.frontend.MessageHandler import de.kif.frontend.MessageHandler
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -25,13 +26,13 @@ object UserRepository : Repository<User> {
} }
override suspend fun create(model: User): Long { override suspend fun create(model: User): Long {
return repositoryPost("$prefix/api/users", Message.json.stringify(User.serializer(), model)) return repositoryPost("$prefix/api/users", Serialization.stringify(User.serializer(), model))
?: throw IllegalStateException("Cannot create model!") ?: throw IllegalStateException("Cannot create model!")
} }
override suspend fun update(model: User) { override suspend fun update(model: User) {
if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") if (model.id == null) throw IllegalStateException("Cannot update model which was not created!")
repositoryPost("$prefix/api/user/${model.id}", Message.json.stringify(User.serializer(), model)) repositoryPost("$prefix/api/user/${model.id}", Serialization.stringify(User.serializer(), model))
} }
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {

View file

@ -3,6 +3,7 @@ package de.kif.frontend.repository
import de.kif.common.Message import de.kif.common.Message
import de.kif.common.Repository import de.kif.common.Repository
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.WorkGroup import de.kif.common.model.WorkGroup
import de.kif.frontend.MessageHandler import de.kif.frontend.MessageHandler
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -25,13 +26,13 @@ object WorkGroupRepository : Repository<WorkGroup> {
} }
override suspend fun create(model: WorkGroup): Long { override suspend fun create(model: WorkGroup): Long {
return repositoryPost("$prefix/api/workgroups", Message.json.stringify(WorkGroup.serializer(), model)) return repositoryPost("$prefix/api/workgroups", Serialization.stringify(WorkGroup.serializer(), model))
?: throw IllegalStateException("Cannot create model!") ?: throw IllegalStateException("Cannot create model!")
} }
override suspend fun update(model: WorkGroup) { override suspend fun update(model: WorkGroup) {
if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") if (model.id == null) throw IllegalStateException("Cannot update model which was not created!")
repositoryPost("$prefix/api/workgroup/${model.id}", Message.json.stringify(WorkGroup.serializer(), model)) repositoryPost("$prefix/api/workgroup/${model.id}", Serialization.stringify(WorkGroup.serializer(), model))
} }
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {

View file

@ -58,7 +58,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
border-bottom: solid 1px var(--table-border-color); border-bottom: solid 1px var(--table-border-color);
box-shadow: 0 0 4px black; box-shadow: 0 0 4px var(--shadow-color);
top: -1rem; top: -1rem;
padding-top: 1.2rem; padding-top: 1.2rem;
} }

View file

@ -160,3 +160,25 @@ a {
} }
} }
} }
.offline-banner {
position: fixed;
bottom: 3rem;
left: 0;
right: 0;
height: 3rem;
line-height: 3rem;
text-align: center;
font-size: 1.2rem;
background-color: var(--primary-color);
color: var(--primary-text-color);
z-index: 12;
box-shadow: 0 1px 4px var(--shadow-color);
display: none;
}
body.offline {
.offline-banner {
display: block;
}
}

View file

@ -3,10 +3,7 @@ package de.kif.backend.repository
import de.kif.backend.database.DbWorkGroup import de.kif.backend.database.DbWorkGroup
import de.kif.backend.database.dbQuery import de.kif.backend.database.dbQuery
import de.kif.backend.util.PushService import de.kif.backend.util.PushService
import de.kif.common.Message import de.kif.common.*
import de.kif.common.MessageType
import de.kif.common.Repository
import de.kif.common.RepositoryType
import de.kif.common.model.Constraint import de.kif.common.model.Constraint
import de.kif.common.model.WorkGroup import de.kif.common.model.WorkGroup
import de.westermann.kobserve.event.EventHandler import de.westermann.kobserve.event.EventHandler
@ -36,8 +33,8 @@ object WorkGroupRepository : Repository<WorkGroup> {
val accessible = row[DbWorkGroup.accessible] val accessible = row[DbWorkGroup.accessible]
val length = row[DbWorkGroup.length] val length = row[DbWorkGroup.length]
val language = row[DbWorkGroup.language] val language = row[DbWorkGroup.language]
val leader = Message.json.parse(String.serializer().list, row[DbWorkGroup.leader]) val leader = Serialization.parse(String.serializer().list, row[DbWorkGroup.leader])
val constraints = Message.json.parse(Constraint.serializer().list, row[DbWorkGroup.constraints]) val constraints = Serialization.parse(Constraint.serializer().list, row[DbWorkGroup.constraints])
val createdAt = row[DbWorkGroup.createdAt] val createdAt = row[DbWorkGroup.createdAt]
val updatedAt = row[DbWorkGroup.updatedAt] val updatedAt = row[DbWorkGroup.updatedAt]
@ -92,8 +89,8 @@ object WorkGroupRepository : Repository<WorkGroup> {
it[accessible] = model.accessible it[accessible] = model.accessible
it[length] = model.length it[length] = model.length
it[language] = model.language it[language] = model.language
it[leader] = Message.json.stringify(String.serializer().list, model.leader) it[leader] = Serialization.stringify(String.serializer().list, model.leader)
it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) it[constraints] = Serialization.stringify(Constraint.serializer().list, model.constraints)
it[createdAt] = now it[createdAt] = now
it[updatedAt] = now it[updatedAt] = now
@ -124,8 +121,8 @@ object WorkGroupRepository : Repository<WorkGroup> {
it[accessible] = model.accessible it[accessible] = model.accessible
it[length] = model.length it[length] = model.length
it[language] = model.language it[language] = model.language
it[leader] = Message.json.stringify(String.serializer().list, model.leader) it[leader] = Serialization.stringify(String.serializer().list, model.leader)
it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) it[constraints] = Serialization.stringify(Constraint.serializer().list, model.constraints)
it[updatedAt] = now it[updatedAt] = now
} }

View file

@ -4,6 +4,7 @@ import de.kif.backend.database.Connection
import de.kif.backend.repository.* import de.kif.backend.repository.*
import de.kif.common.Message import de.kif.common.Message
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.* import de.kif.common.model.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -42,12 +43,12 @@ data class Backup(
} }
} }
return Message.json.stringify(serializer(), backup) return Serialization.stringify(serializer(), backup)
} }
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
suspend fun import(data: String) { suspend fun import(data: String) {
val backup = Message.json.parse(serializer(), data) val backup = Serialization.parse(serializer(), data)
backup.users.forEach { UserRepository.create(it); println("Import user ${it.username}") } backup.users.forEach { UserRepository.create(it); println("Import user ${it.username}") }
backup.posts.forEach { PostRepository.create(it); println("Import post") } backup.posts.forEach { PostRepository.create(it); println("Import post") }

View file

@ -1,48 +1,60 @@
package de.kif.backend.util package de.kif.backend.util
import de.kif.backend.prefix
import de.kif.backend.repository.* import de.kif.backend.repository.*
import de.kif.common.* import de.kif.backend.route.api.error
import de.kif.backend.route.api.success
import de.kif.common.Message
import de.kif.common.MessageBox
import de.kif.common.MessageType
import de.kif.common.RepositoryType import de.kif.common.RepositoryType
import io.ktor.http.cio.websocket.Frame import io.ktor.application.call
import io.ktor.http.cio.websocket.readText import io.ktor.http.HttpStatusCode
import io.ktor.routing.Route import io.ktor.routing.Route
import io.ktor.websocket.WebSocketServerSession import io.ktor.routing.get
import io.ktor.websocket.webSocket
import kotlinx.coroutines.channels.ClosedReceiveChannelException
object PushService { object PushService {
var clients: List<WebSocketServerSession> = emptyList() var leastValidTimestamp = System.currentTimeMillis()
suspend fun notify(type: MessageType, repository: RepositoryType, id: Long) { /**
try { * Save the message with the current timestamp
val data = Message(type, repository, id).stringify() */
for (client in clients) { fun notify(type: MessageType, repository: RepositoryType, id: Long) {
client.outgoing.send(Frame.Text(data)) val timestamp = System.currentTimeMillis()
val message = Message(type, repository, id)
} }
} catch (e: Exception) {
e.printStackTrace() /**
* Get all messages created after the given timestamp.
* The return Box has the current timestamp. If the leastValidTimestamp is less then the current timestamp set the
* valid flag to false and return an empty message list.
*/
fun getMessages(timestamp: Long?): MessageBox {
return MessageBox(
System.currentTimeMillis(),
true,
emptyList()
)
} }
/**
* Delete all messages before the given timestamp
*/
fun gc(timestamp: Long) {
leastValidTimestamp = timestamp
} }
} }
fun Route.pushService() { fun Route.pushService() {
webSocket("/websocket") { get("/api/updates") {
PushService.clients += this
try { try {
while (true) { val timestamp = call.request.queryParameters["timestamp"]?.toLongOrNull()
val text = (incoming.receive() as Frame.Text).readText()
println("onMessage($text)") val messageBox = PushService.getMessages(timestamp)
outgoing.send(Frame.Text(text))
} call.success(messageBox)
} catch (_: ClosedReceiveChannelException) { } catch (_: Exception) {
PushService.clients -= this call.error(HttpStatusCode.InternalServerError)
} catch (e: Throwable) {
println("onFailure ${closeReason.await()}")
e.printStackTrace()
PushService.clients -= this
} }
} }

View file

@ -73,6 +73,8 @@ class MainTemplate(
} }
} }
body { body {
attributes["data-timestamp"] = currentTimeMillis().toString()
if (!noMenu) { if (!noMenu) {
insert(MenuTemplate(url, user)) {} insert(MenuTemplate(url, user)) {}
} }
@ -84,6 +86,10 @@ class MainTemplate(
} }
} }
div("offline-banner") {
span { +"Die Verbindung zum Server ist unterbrochen" }
}
if (!noMenu) { if (!noMenu) {
div("footer") { div("footer") {
div("container") { div("container") {