Skip to content

Commit

Permalink
feat(ui): add server status tracking and prevent doing certain operat…
Browse files Browse the repository at this point in the history
…ions if the server status endpoint does not return success
  • Loading branch information
updraft0 committed Jun 12, 2024
1 parent 42619cf commit 3b58e13
Show file tree
Hide file tree
Showing 16 changed files with 170 additions and 28 deletions.
12 changes: 8 additions & 4 deletions esi-client/src/main/scala/org/updraft0/esi/client/client.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,17 @@ final class EsiClient(config: EsiClient.Config, sttp: SttpClient, interp: SttpCl
jwtClientDecodeErrors(Endpoints.getCharacterShip).andThen(_.andThen(_.orDie.absolve))

val getCharacter: CharacterId => IO[EsiError, Character] =
interp
.toClientThrowDecodeFailures(Endpoints.getCharacter, Some(config.base), sttp)
.andThen(_.orDie.absolve)
noAuthClientDecodeErrors(Endpoints.getCharacter)

val getCharacterAffiliations: List[CharacterId] => IO[EsiError, List[CharacterAffiliation]] =
noAuthClientDecodeErrors(Endpoints.getCharacterAffiliations)

val getServerStatus: Unit => IO[EsiError, ServerStatusResponse] =
noAuthClientDecodeErrors(Endpoints.getStatus)

private def noAuthClientDecodeErrors[I, O](e: Endpoint[Unit, I, EsiError, O, Any]) =
interp
.toClientThrowDecodeFailures(Endpoints.getCharacterAffiliations, Some(config.base), sttp)
.toClientThrowDecodeFailures(e, Some(config.base), sttp)
.andThen(_.orDie.absolve)

private def jwtClient[A, I, E, O](e: Endpoint[A, I, E, O, Any]) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ case class CharacterShipResponse(
shipName: String,
shipTypeId: Int
)
case class ServerStatusResponse(players: Int, serverVersion: String, startTime: String, vip: Option[Boolean])

enum FleetError:
case NotInFleet
Expand Down Expand Up @@ -76,6 +77,13 @@ object Endpoints:
.out(jsonBody[JwtAuthResponse])
.errorOut(jsonBody[AuthErrorResponse])

// status
val getStatus = endpoint.get
.in("v2" / "status")
.out(jsonBody[ServerStatusResponse])
.errorOut(esiErrorOut)
.description("Get server status")

// character
val getCharacterRoles = jwtEndpoint.get
.in("v3" / "characters" / path[CharacterId] / "roles")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ object schema:
given Schema[EsiError.NotFound] = Schema.derived
given Schema[EsiError.Forbidden] = Schema.derived

// status
given Schema[ServerStatusResponse] = Schema.derived

object jsoncodec:
// debug print codecs
// given CodecMakerConfig.PrintCodec with {}
Expand Down Expand Up @@ -90,3 +93,6 @@ object jsoncodec:
given JsonValueCodec[CharacterLocationResponse] = JsonCodecMaker.make(config)
given JsonValueCodec[CharacterOnlineResponse] = JsonCodecMaker.make(config)
given JsonValueCodec[CharacterShipResponse] = JsonCodecMaker.make(config)

// status
given JsonValueCodec[ServerStatusResponse] = JsonCodecMaker.make(config)
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ case class MapSystemSnapshot(
connections: Array[MapWormholeConnection]
) derives CanEqual

enum MapServerStatus derives CanEqual:
case Error
case Online(players: Int, version: String, startedAt: String, vip: Boolean)

enum NewSystemName derives CanEqual:
case None
case Name(value: String)
Expand Down Expand Up @@ -325,5 +329,6 @@ enum MapMessage:
removedConnectionIds: Array[ConnectionId],
connections: Map[ConnectionId, MapWormholeConnectionWithSigs]
)
case ServerStatus(status: MapServerStatus)

// endregion
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ object schema:
given Schema[MapSystemSignature] = Schema.derived
given Schema[MapSystemSignature.Wormhole] = Schema.derived
given Schema[MapSystemSnapshot] = Schema.derived
given Schema[MapServerStatus] = Schema.derived
given Schema[NewSystemSignature] = Schema.derived

object jsoncodec:
Expand Down Expand Up @@ -169,6 +170,7 @@ object jsoncodec:
given JsonCodec[MapWormholeConnectionJump] = JsonCodec.derived
given JsonCodec[MapWormholeConnectionRank] = JsonCodec.derived
given JsonCodec[MapWormholeConnectionWithSigs] = JsonCodec.derived
given JsonCodec[MapServerStatus] = JsonCodec.derived
given JsonCodec[SignatureGroup] = JsonCodec.string.transform(SignatureGroup.valueOf, _.toString)
given JsonCodec[WormholeMassSize] = JsonCodec.string.transform(WormholeMassSize.valueOf, _.toString)
given JsonCodec[WormholeMassStatus] = JsonCodec.string.transform(WormholeMassStatus.valueOf, _.toString)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.updraft0.controltower.server.map

import org.updraft0.controltower.constant.{SystemId => _, *}
import org.updraft0.controltower.constant.{SystemId as _, *}
import org.updraft0.controltower.db.model.{
MapSystemSignature,
MapWormholeConnection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import org.updraft0.controltower.server.db.{
MapWormholeConnectionWithSigs
}
import org.updraft0.controltower.server.endpoints.{toMapInfo, toProtocolRole}
import org.updraft0.controltower.server.tracking.{CharacterLocationState, LocationTracker, LocationTrackingRequest}
import org.updraft0.controltower.server.tracking.{
CharacterLocationState,
LocationTracker,
LocationTrackingRequest,
ServerStatusTracker
}
import zio.*
import zio.http.ChannelEvent.UserEvent
import zio.http.{ChannelEvent, Handler, WebSocketChannelEvent, WebSocketFrame}
Expand All @@ -38,7 +43,7 @@ enum MapSessionMessage:
* socket closure (here we do `Channel.awaitShutdown` to close the manually-created scope and release the resources)
*/
object MapSession:
type Env = MapReactive.Service & javax.sql.DataSource & LocationTracker
type Env = MapReactive.Service & javax.sql.DataSource & LocationTracker & ServerStatusTracker

private val jsonContent = LogAnnotation[String]("json", (_, b) => b, identity)
private val errorMessage = LogAnnotation[String]("error", (_, b) => b, identity)
Expand All @@ -51,6 +56,8 @@ object MapSession:
*/
private val PingInterval = 1.minute

private val ServerStatusInterval = 1.minute

private case class Context(
mapId: MapId,
character: model.AuthCharacter,
Expand Down Expand Up @@ -116,6 +123,11 @@ object MapSession:
_ <- sessionMessages.take.flatMap(handleMapSessionMessage(character.id, mapRole, close, _)).forever.forkScoped
// ping out every ping interval to keep connection open
_ <- chan.send(ChannelEvent.Read(WebSocketFrame.Ping)).schedule(Schedule.fixed(PingInterval)).ignore.forkDaemon
// listen for server status
_ <- sendServerStatus(ourQ)
.schedule(Schedule.once.andThen(Schedule.fixed(ServerStatusInterval)))
.ignore
.forkDaemon
// join on the remaining loops
_ <- recv.join
_ <- send.join
Expand Down Expand Up @@ -255,6 +267,18 @@ object MapSession:
)
)

private def sendServerStatus(ourQ: Enqueue[protocol.MapMessage]) =
ZIO
.serviceWithZIO[ServerStatusTracker](_.status)
.flatMap:
case Left(_) => ourQ.offer(protocol.MapMessage.ServerStatus(protocol.MapServerStatus.Error))
case Right(s) =>
ourQ.offer(
protocol.MapMessage.ServerStatus(
protocol.MapServerStatus.Online(s.players, s.serverVersion, s.startTime, s.vip.contains(true))
)
)

private def isAllowed(msg: protocol.MapRequest, role: model.MapRole): Boolean = (msg, role) match
case (protocol.MapRequest.GetMapInfo | protocol.MapRequest.GetSnapshot, _) => true
case (_, model.MapRole.Viewer) => false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import java.security.SecureRandom
*/
object Server extends ZIOAppDefault:
type EndpointEnv = Config & javax.sql.DataSource & SessionCrypto & EsiClient & SdeClient & UserSession &
MapReactive.Service & TokenCrypto & SecureRandom & MapPermissionTracker & CharacterAuthTracker & LocationTracker
MapReactive.Service & TokenCrypto & SecureRandom & MapPermissionTracker & CharacterAuthTracker & LocationTracker &
ServerStatusTracker

override val bootstrap = Runtime.enableRuntimeMetrics >>> desktopLogger

Expand Down Expand Up @@ -63,6 +64,7 @@ object Server extends ZIOAppDefault:
TokenCrypto.layer,
ZLayer(ZIO.attempt(new SecureRandom())),
MapPermissionTracker.layer,
ServerStatusTracker.layer,
ZLayer.scoped(CharacterAffiliationTracker.apply())
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package org.updraft0.controltower.server.tracking

import org.updraft0.controltower.constant.CharacterId
import org.updraft0.controltower.server.auth.Users
import org.updraft0.controltower.server.Log
import org.updraft0.esi.client.EsiClient
import org.updraft0.controltower.server.auth.Users
import org.updraft0.esi.client.{EsiClient, ServerStatusResponse}
import zio.*
import zio.stream.ZStream

object CharacterAffiliationTracker:
type Env = EsiClient & Users.Env
type Env = ServerStatusTracker & EsiClient & Users.Env

// Every poll interval, update character affiliations
private val PollInterval = 10.minutes
Expand All @@ -25,18 +25,28 @@ object CharacterAffiliationTracker:
.unit

private def refreshAll: ZIO[Env, Throwable, Unit] =
Users.allCharacters
.flatMap(allChars =>
ZStream
.fromChunk(allChars)
.grouped(EsiMaxCharacterPerBatch)
.mapZIO(getAndSaveAffiliations)
.runDrain
)
ZIO
.serviceWithZIO[ServerStatusTracker](_.status)
.flatMap:
case Left(_) => ZIO.logDebug("Not refreshing due to errored service status")
case Right(s) if !s.isOnlineEnough =>
ZIO.logWarning("Not refreshing due to server not having enough players online or being VIP")
case Right(s) =>
Users.allCharacters
.flatMap(allChars =>
ZStream
.fromChunk(allChars)
.grouped(EsiMaxCharacterPerBatch)
.mapZIO(getAndSaveAffiliations)
.runDrain
)
.retry(Schedule.exponential(2.seconds) && Schedule.recurs(3))

private def getAndSaveAffiliations(charIds: Chunk[CharacterId]) =
ZIO
.serviceWithZIO[EsiClient](_.getCharacterAffiliations(charIds.toList))
.tapError(ex => ZIO.logWarning(s"Updating character affiliations for $charIds failed due to $ex"))
.mapError(_ => new RuntimeException("Updating character affiliations failed due to ESI error"))
.flatMap(xs => Users.updateAffiliations(xs.map(ca => (ca.characterId, ca.corporationId, ca.allianceId))))

extension (v: ServerStatusResponse) def isOnlineEnough: Boolean = v.players > 100 && v.vip.forall(!_)
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package org.updraft0.controltower.server.tracking
import org.updraft0.controltower.constant.*
import org.updraft0.controltower.server.auth.CharacterAuth
import org.updraft0.controltower.server.Log
import org.updraft0.esi.client.{EsiClient, EsiError}
import org.updraft0.esi.client.{EsiClient, EsiError, ServerStatusResponse}
import zio.*

import java.time.Instant
Expand Down Expand Up @@ -44,6 +44,8 @@ trait LocationTracker:
def updates: URIO[Scope, Dequeue[LocationUpdate]]

object LocationTracker:
type Env = EsiClient & CharacterAuthTracker & ServerStatusTracker & Config

private val InCapacity = 64
private val InternalHubCapacity = 64

Expand All @@ -60,10 +62,10 @@ object LocationTracker:

case class Config(interval: Duration, maxParallel: Int)

def layer: ZLayer[EsiClient & CharacterAuthTracker & Config, Throwable, LocationTracker] =
def layer: ZLayer[Env, Throwable, LocationTracker] =
ZLayer.scoped(ZIO.serviceWithZIO[Config](c => apply(c)).tap(subscribeToAuthUpdates))

def apply(c: Config): ZIO[Scope & EsiClient, Throwable, LocationTracker] =
def apply(c: Config): ZIO[Scope & Env, Throwable, LocationTracker] =
for
// create services
esi <- ZIO.service[EsiClient]
Expand Down Expand Up @@ -138,8 +140,23 @@ object LocationTracker:
state: Ref[TrackerState],
response: Enqueue[LocationUpdate],
parallel: Int
) =
ZIO
.serviceWithZIO[ServerStatusTracker](_.status)
.flatMap:
case Left(err) => ZIO.logDebug(s"Not refreshing locations due to server status error: ${err}")
case Right(s) if !s.isOnlineEnough => ZIO.logDebug("Not refreshing locations due to not being online enough")
case Right(_) => refreshLocationsInner(esi, state, response, parallel)

private def refreshLocationsInner(
esi: EsiClient,
state: Ref[TrackerState],
response: Enqueue[LocationUpdate],
parallel: Int
) =
for
status <- ZIO.serviceWithZIO[ServerStatusTracker](_.status)

curr <- state.get
now <- ZIO.clockWith(_.instant)
withAuth = curr.charState.view.filter(_._2.auth.isDefined).values
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.updraft0.controltower.server.tracking

import org.updraft0.controltower.server.Log
import org.updraft0.esi.client.{EsiClient, EsiError, ServerStatusResponse}
import zio.*

trait ServerStatusTracker:
def status: UIO[Either[EsiError, ServerStatusResponse]]

object ServerStatusTracker:
type Env = EsiClient

// Every poll interval, update status
private val PollInterval = 30.seconds

def layer: ZLayer[Env, Nothing, ServerStatusTracker] = ZLayer.scoped(apply())

def apply(): ZIO[Scope & Env, Nothing, ServerStatusTracker] =
for
ref <- Ref.make[Either[EsiError, ServerStatusResponse]](
Left(EsiError.ServiceUnavailable("Internal tracker error"))
)
// refresh once
_ <- doRefresh(ref)
// fork in background
_ <- (doRefresh(ref)
.repeat(Schedule.once.andThen(Schedule.fixed(PollInterval)))
.ignoreLogged @@ Log.BackgroundOperation("statusTracker")).forkScoped
yield new ServerStatusTracker:
override def status: UIO[Either[EsiError, ServerStatusResponse]] = ref.get

private def doRefresh(ref: Ref[Either[EsiError, ServerStatusResponse]]) =
ZIO
.serviceWithZIO[EsiClient](_.getServerStatus(()).either)
.flatMap(ref.set(_))
2 changes: 1 addition & 1 deletion ui/src/main/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;

scrollbar-color: $gray-darker $gray-dark;
scrollbar-color: $teal-dark $gray-dark;
}


Expand Down
6 changes: 5 additions & 1 deletion ui/src/main/css/views/nav-top-view.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ div#nav-top-view {
}

/* connection status icon */
& span.connection-status {
& span.connection-status, & span.eve-server-status {
padding-top: 0.2em;
height: 105%;
}
Expand All @@ -96,4 +96,8 @@ div#nav-top-view {
& span.connection-status.ti-unlink {
color: $red;
}

& span.eve-server-status.ti-satellite {
color: $green-dark;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ class MapController(rds: ReferenceDataStore, val clock: Signal[Instant])(using O
val requestBus = EventBus[MapRequest]()
val responseBus = EventBus[MapMessage]()

val mapMeta = Var[Option[MapMessage.MapMeta]](None)
val mapMeta = Var[Option[MapMessage.MapMeta]](None)
val serverStatus = Var[MapServerStatus](MapServerStatus.Error)

val allSystems = HVar[Map[Long, MapSystemSnapshot]](Map.empty)
val allConnections = HVar[Map[ConnectionId, MapWormholeConnectionWithSigs]](Map.empty)
Expand Down Expand Up @@ -110,7 +111,8 @@ class MapController(rds: ReferenceDataStore, val clock: Signal[Instant])(using O
allConnections.current -> Map.empty,
allLocations.current -> Map.empty,
selectedSystemId -> Option.empty,
selectedConnectionId -> Option.empty
selectedConnectionId -> Option.empty,
serverStatus -> MapServerStatus.Error
)
pos.clear()

Expand Down Expand Up @@ -276,6 +278,7 @@ class MapController(rds: ReferenceDataStore, val clock: Signal[Instant])(using O
}
)
)
case MapMessage.ServerStatus(status) => serverStatus.set(status)

object MapController:
private val DefaultMapSettings = MapSettings(staleScanThreshold = Duration.ofHours(6))
Expand Down
Loading

0 comments on commit 3b58e13

Please sign in to comment.