diff --git a/esi-client/src/main/scala/org/updraft0/esi/client/client.scala b/esi-client/src/main/scala/org/updraft0/esi/client/client.scala index 266b831..85ccac5 100644 --- a/esi-client/src/main/scala/org/updraft0/esi/client/client.scala +++ b/esi-client/src/main/scala/org/updraft0/esi/client/client.scala @@ -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]) = diff --git a/esi-client/src/main/scala/org/updraft0/esi/client/endpoints.scala b/esi-client/src/main/scala/org/updraft0/esi/client/endpoints.scala index 90ab053..b5ccdb1 100644 --- a/esi-client/src/main/scala/org/updraft0/esi/client/endpoints.scala +++ b/esi-client/src/main/scala/org/updraft0/esi/client/endpoints.scala @@ -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 @@ -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") diff --git a/esi-client/src/main/scala/org/updraft0/esi/client/schema.scala b/esi-client/src/main/scala/org/updraft0/esi/client/schema.scala index 9c0e7d2..5467fa9 100644 --- a/esi-client/src/main/scala/org/updraft0/esi/client/schema.scala +++ b/esi-client/src/main/scala/org/updraft0/esi/client/schema.scala @@ -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 {} @@ -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) diff --git a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala index b307bf6..7ae9895 100644 --- a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala +++ b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala @@ -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) @@ -325,5 +329,6 @@ enum MapMessage: removedConnectionIds: Array[ConnectionId], connections: Map[ConnectionId, MapWormholeConnectionWithSigs] ) + case ServerStatus(status: MapServerStatus) // endregion diff --git a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/schema.scala b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/schema.scala index 98a0b31..8bc0baf 100644 --- a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/schema.scala +++ b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/schema.scala @@ -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: @@ -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) diff --git a/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala b/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala index c04874b..2e0ffa0 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala @@ -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, diff --git a/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala b/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala index fca93bd..367b18e 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala @@ -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} @@ -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) @@ -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, @@ -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 @@ -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 diff --git a/server/src/main/scala/org/updraft0/controltower/server/server.scala b/server/src/main/scala/org/updraft0/controltower/server/server.scala index 88068e2..b7df295 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/server.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/server.scala @@ -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 @@ -63,6 +64,7 @@ object Server extends ZIOAppDefault: TokenCrypto.layer, ZLayer(ZIO.attempt(new SecureRandom())), MapPermissionTracker.layer, + ServerStatusTracker.layer, ZLayer.scoped(CharacterAffiliationTracker.apply()) ) diff --git a/server/src/main/scala/org/updraft0/controltower/server/tracking/CharacterAffiliationTracker.scala b/server/src/main/scala/org/updraft0/controltower/server/tracking/CharacterAffiliationTracker.scala index c9d5ede..a50b866 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/tracking/CharacterAffiliationTracker.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/tracking/CharacterAffiliationTracker.scala @@ -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 @@ -25,14 +25,22 @@ 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 @@ -40,3 +48,5 @@ object CharacterAffiliationTracker: .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(!_) diff --git a/server/src/main/scala/org/updraft0/controltower/server/tracking/LocationTracker.scala b/server/src/main/scala/org/updraft0/controltower/server/tracking/LocationTracker.scala index 4364805..402c5a8 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/tracking/LocationTracker.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/tracking/LocationTracker.scala @@ -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 @@ -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 @@ -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] @@ -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 diff --git a/server/src/main/scala/org/updraft0/controltower/server/tracking/ServerStatusTracker.scala b/server/src/main/scala/org/updraft0/controltower/server/tracking/ServerStatusTracker.scala new file mode 100644 index 0000000..1a526fb --- /dev/null +++ b/server/src/main/scala/org/updraft0/controltower/server/tracking/ServerStatusTracker.scala @@ -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(_)) diff --git a/ui/src/main/css/app.css b/ui/src/main/css/app.css index d197e0f..1c73ef4 100644 --- a/ui/src/main/css/app.css +++ b/ui/src/main/css/app.css @@ -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; } diff --git a/ui/src/main/css/views/nav-top-view.css b/ui/src/main/css/views/nav-top-view.css index e43c751..0afa48e 100644 --- a/ui/src/main/css/views/nav-top-view.css +++ b/ui/src/main/css/views/nav-top-view.css @@ -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%; } @@ -96,4 +96,8 @@ div#nav-top-view { & span.connection-status.ti-unlink { color: $red; } + + & span.eve-server-status.ti-satellite { + color: $green-dark; + } } \ No newline at end of file diff --git a/ui/src/main/scala/controltower/page/map/view/MapController.scala b/ui/src/main/scala/controltower/page/map/view/MapController.scala index 121b109..dd82f21 100644 --- a/ui/src/main/scala/controltower/page/map/view/MapController.scala +++ b/ui/src/main/scala/controltower/page/map/view/MapController.scala @@ -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) @@ -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() @@ -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)) diff --git a/ui/src/main/scala/controltower/page/map/view/MapView.scala b/ui/src/main/scala/controltower/page/map/view/MapView.scala index 27a4dbb..e781b40 100644 --- a/ui/src/main/scala/controltower/page/map/view/MapView.scala +++ b/ui/src/main/scala/controltower/page/map/view/MapView.scala @@ -92,7 +92,15 @@ private class MapView( val connectingSystem = HVar(MapNewConnectionState.Stopped) val navTopView = - NavTopView(page.name, controller.mapMetaSignal, controller.allLocations.signal, time, ws.isConnected, ct) + NavTopView( + page.name, + controller.mapMetaSignal, + controller.allLocations.signal, + time, + ws.isConnected, + controller.serverStatus.signal, + ct + ) val systemInfoView = SolarSystemInfoView( static, diff --git a/ui/src/main/scala/controltower/page/map/view/NavTopView.scala b/ui/src/main/scala/controltower/page/map/view/NavTopView.scala index 6386b94..8e915df 100644 --- a/ui/src/main/scala/controltower/page/map/view/NavTopView.scala +++ b/ui/src/main/scala/controltower/page/map/view/NavTopView.scala @@ -18,6 +18,7 @@ class NavTopView( locations: Signal[Map[constant.SystemId, Array[CharacterLocation]]], time: Signal[Instant], isConnected: Signal[Boolean], + serverStatus: Signal[MapServerStatus], ct: ControlTowerBackend ) extends ViewController: @@ -30,7 +31,8 @@ class NavTopView( userInfo(mapName, mapMeta.map(_.character), mapMeta.map(_.role)), locationStatus(locations), timeStatus(time), - connectionStatus(isConnected) + connectionStatus(isConnected), + eveServerStatus(isConnected, serverStatus) ) private def editMapButton(using ControlTowerBackend) = @@ -83,6 +85,18 @@ private def connectionStatus(isConnected: Signal[Boolean]) = cls <-- isConnected.map(c => if (c) "ti-link" else "ti-unlink") ) +private def eveServerStatus(isConnected: Signal[Boolean], status: Signal[MapServerStatus]) = + span( + cls := "eve-server-status", + cls := "right-block", + cls := "ti", + cls <-- isConnected.combineWith(status).map { + case (false, _) => "ti-satellite-off" + case (_, MapServerStatus.Error) => "ti-satellite-off" + case (true, _: MapServerStatus.Online) => "ti-satellite" + } + ) + // TODO move this somewhere else private val SecondsInDay = 24 * 60 * 60 private val SecondsFromZeroToEpoch = ((146097L * 5L) - (30L * 365L + 7L)) * SecondsInDay