Skip to content

Commit

Permalink
Merge pull request #104 from lsst-sqre/tickets/DM-36258
Browse files Browse the repository at this point in the history
DM-36258: Deploy heartbeats to RubinTV GUI and add reload historical button
  • Loading branch information
jonathansick committed Sep 28, 2022
2 parents bc5f2ba + 9ca804c commit f7eac4a
Show file tree
Hide file tree
Showing 19 changed files with 304 additions and 160 deletions.
11 changes: 9 additions & 2 deletions src/rubintv/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,15 @@ def _get_blobs(self) -> List[Bucket]:
print(f"blobs found: {len(blobs)}")
return blobs

def _get_events(self) -> Dict[str, Dict[str, List[Event]]]:
if not self._events or get_current_day_obs() > self._lastCall:
def reset(self) -> None:
self._events = self._get_events(reset=True)
self._lastCall = get_current_day_obs()
return

def _get_events(
self, reset: bool = False
) -> Dict[str, Dict[str, List[Event]]]:
if not self._events or get_current_day_obs() > self._lastCall or reset:
blobs = self._get_blobs()
self._events = self._sort_events_from_blobs(blobs)
self._lastCall = get_current_day_obs()
Expand Down
47 changes: 24 additions & 23 deletions src/rubintv/handlers/external/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
]

import json
from dataclasses import asdict
from datetime import date, datetime, timedelta
from typing import Any, Dict, Iterator, List, Optional

Expand Down Expand Up @@ -36,18 +37,27 @@ async def get_page(request: web.Request) -> dict[str, Any]:
@routes.get("/admin")
@template("admin.jinja")
async def get_admin_page(request: web.Request) -> dict[str, Any]:
bucket = request.config_dict["rubintv/gcs_bucket"]
title = build_title("Admin", request=request)

heartbeats = get_heartbeats(bucket, HEARTBEATS_PREFIX)

return {
"title": title,
"services": production_services,
"heartbeats": heartbeats,
}


@routes.post("/reload_historical")
async def reload_historical(request: web.Request) -> web.Response:
cams_with_history = [cam for cam in cameras.values() if cam.has_historical]
historical = request.config_dict["rubintv/historical_data"]
historical.reset()
latest = historical.get_most_recent_event(cams_with_history[0])
latest_dict = asdict(latest)
# datetime can't be serialized so replace with string
the_date = latest.cleanDate()
latest_dict["date"] = the_date
json_res = json.dumps({"most_recent_historical_event": latest_dict})
return web.Response(text=json_res, content_type="application/json")


@routes.get("/admin/heartbeat/{heartbeat_prefix}")
async def request_heartbeat_for_channel(request: web.Request) -> web.Response:
bucket = request.config_dict["rubintv/gcs_bucket"]
Expand Down Expand Up @@ -75,9 +85,10 @@ def get_heartbeats(bucket: Bucket, prefix: str) -> List[Dict]:
heartbeats = []
for hb_blob in hb_blobs:
try:
blob_content = hb_blob.download_as_string()
except NotFound as e:
print(f"Error: {hb_blob.name} not found.\n{e.errors()}")
the_blob = bucket.get_blob(hb_blob.name)
blob_content = the_blob.download_as_string()
except NotFound:
print(f"Error: {hb_blob.name} not found.")
if not blob_content:
continue
hb = json.loads(blob_content)
Expand Down Expand Up @@ -383,23 +394,13 @@ def get_channel_resource_url(

def get_metadata_json(
bucket: Bucket, camera: Camera, a_date: date, logger: Any
) -> str:
) -> dict:
date_str = a_date.strftime("%Y%m%d")
prefix = f"{camera.slug}_metadata/dayObs_{date_str}.json"
metadata_json = "{}"
metadata_url = get_metadata_url(bucket.name, camera.slug, date_str)
try:
metadata_res = requests.get(metadata_url)
if metadata_res.status_code == 200:
metadata_json = metadata_res.text
except requests.exceptions.RequestException as e:
logger.error(f"Error retrieving from {metadata_url} with error: {e}")
return metadata_json


def get_metadata_url(bucket_name: str, camera_slug: str, date_str: str) -> str:
url = f"https://storage.googleapis.com/{bucket_name}/"
url += f"{camera_slug}_metadata/dayObs_{date_str}.json"
return url
if blobs := list(bucket.list_blobs(prefix=prefix)):
metadata_json = blobs[0].download_as_string()
return json.loads(metadata_json)


def month_names() -> List[str]:
Expand Down
17 changes: 7 additions & 10 deletions src/rubintv/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Channel:
simplename: str
label: str = ""
endpoint: str = field(init=False)
service_dependency: str = ""

def __post_init__(self) -> None:
self.endpoint = self.simplename + "events"
Expand Down Expand Up @@ -84,18 +85,21 @@ def __post_init__(self) -> None:
prefix="auxtel_monitor",
simplename="monitor",
label="Monitor",
service_dependency="auxtel_isr_runner",
),
"im": Channel(
name="Image Analysis",
prefix="summit_imexam",
simplename="im",
label="ImAnalysis",
service_dependency="auxtel_isr_runner",
),
"spec": Channel(
name="Spectrum",
prefix="summit_specexam",
simplename="spec",
label="Spectrum",
service_dependency="auxtel_isr_runner",
),
"mount": Channel(
name="Mount",
Expand Down Expand Up @@ -131,28 +135,21 @@ def __post_init__(self) -> None:
production_services = {
"auxtel": {
"display_name": "AuxTel",
"channels": {
"auxtel_monitor": "Monitor",
"summit_imexam": "Image Analysis",
"summit_specexam": "Spectrum",
"auxtel_mount_torques": "Mount",
},
"channels": cameras["auxtel"].channels,
"services": {
"auxtel_metadata": "Metadata",
"auxtel_isr_runner": "ISR Runner",
},
},
"allsky": {
"display_name": "All Sky",
"channels": {
"services": {
"allsky": "All Sky",
},
},
"comcam": {
"display_name": "ComCam",
"channels": {
"comcam_monitor": "Central CCD Monitor",
},
"channels": cameras["comcam"].channels,
},
"misc": {
"display_name": "Misc Services",
Expand Down
6 changes: 6 additions & 0 deletions src/rubintv/static/images/heartbeat_channel_stopped.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/rubintv/static/images/pending.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions src/rubintv/static/js/admin-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* global jQuery */
import { ChannelStatus } from './modules/heartbeat.js'

(function ($) {
const services = Array.from(document.querySelectorAll('.service').values()).map(s => s.id)
services.map(s => new ChannelStatus(s))
})(jQuery)
5 changes: 4 additions & 1 deletion src/rubintv/static/js/allsky-refresh.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* global jQuery */
import { ChannelStatus } from './modules/heartbeat.js'

(function ($) {
const urlPath = document.location.pathname
Expand Down Expand Up @@ -29,6 +30,8 @@
}
})
}

setInterval(videoCheckLatest, 5000)

const status = new ChannelStatus('allsky')
console.log(JSON.stringify(status))
})(jQuery)
21 changes: 21 additions & 0 deletions src/rubintv/static/js/camera-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* global jQuery */
import { ChannelStatus } from './modules/heartbeat.js'

(function ($) {
const services = Array.from(document.querySelectorAll('.service')
.values())
// eslint-disable-next-line no-new-object
.map(s => new Object({ id: s.id, dependentOn: s.dataset.dependentOn }))
const dependenciesNames = Array.from(new Set(services.map(s => s.dependentOn)))
// eslint-disable-next-line eqeqeq
.filter(d => !(d === '' || typeof d === 'undefined'))

const dependencies = Object.fromEntries(
dependenciesNames.map(d => [d,
new ChannelStatus(d)])
)

const stats = services
.map(s => new ChannelStatus(s.id, dependencies[s.dependentOn]))
console.log(stats)
})(jQuery)
68 changes: 29 additions & 39 deletions src/rubintv/static/js/modules/heartbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
// }
// timestamp is seconds since epoch (Jan 1, 1970)

class ChannelStatus {
export class ChannelStatus {
// time in secs to try downloading heartbeat again after stale
RETRY = 120
// time in secs to query all blobs to bring in missing services

constructor (heartbeatFromApp) {
this.consumeHeartbeat(heartbeatFromApp)
this.url = heartbeatFromApp.url
this.$el = $(`#${this.channel}`)
this.displayStatus()
this.waitForNextHeartbeat()
constructor (service, dependency = null) {
this.service = service
this.dependency = dependency
this.$el = $(`#${this.service}`)
this.time = 0
this.next = 0
this.updateHeartbeatData()
}

get nextInterval () {
Expand All @@ -29,7 +30,11 @@ class ChannelStatus {
}

get isActive () {
return this.next > this.nowTimestamp
const thisActive = this.next > this.nowTimestamp
if (!this.dependency) {
return thisActive
}
return thisActive && this.dependency.isActive
}

get status () {
Expand All @@ -41,47 +46,50 @@ class ChannelStatus {
}

consumeHeartbeat (heartbeat) {
this.channel = heartbeat.channel
this.service = heartbeat.channel
this.time = heartbeat.currTime
this.next = heartbeat.nextExpected
this.errors = heartbeat.errors
}

waitForNextHeartbeat () {
setTimeout(() => {
if (this.service === 'allsky') {
console.log(`waitForNext(): ${JSON.stringify(this)} `)
console.log(`with interval: ${this.nextInterval}`)
}
this.updateHeartbeatData()
}, this.nextInterval * 1000)
}

updateHeartbeatData () {
let alive = false
this.alive = false
const self = this
const urlPath = document.location.pathname
$.get(`${urlPath}/heartbeat/${this.channel}`, (rawHeartbeat) => {
if (rawHeartbeat) {
self.consumeHeartbeat(rawHeartbeat)
alive = true

$.get(`admin/heartbeat/${this.service}`, (heartbeat) => {
if (!$.isEmptyObject(heartbeat)) {
self.consumeHeartbeat(heartbeat)
this.alive = true
}
}).always(() => {
self.displayStatus(alive)
self.displayStatus(this.alive)
self.waitForNextHeartbeat()
})
}

displayHeartbeatInfo () {
const time = this.time
? new Date(this.time * 1000).toLocaleString('en-US')
? new Date(this.time * 1000).toLocaleString('en-US', { timeZone: 'UTC' }) + ' UTC'
: 'never'

const next = this.isActive
? new Date(this.next * 1000).toLocaleString('en-US')
: new Date((this.nowTimestamp + this.RETRY) * 1000).toLocaleString('en-US')
? new Date(this.next * 1000).toLocaleString('en-US', { timeZone: 'UTC' })
: new Date((this.nowTimestamp + this.RETRY) * 1000).toLocaleString('en-US', { timeZone: 'UTC' })

this.$el.attr({ title: `last heartbeat at: ${time} UTC\nnext check at: ${next} UTC` })
this.$el.attr({ title: `last heartbeat at: ${time}\nnext check at: ${next} UTC` })
}

displayStatus (alive = true) {
// channel in this context is the same as channel.prefix used in the template
if (alive) {
this.$el.removeClass('stopped').addClass(this.status)
} else {
Expand All @@ -90,21 +98,3 @@ class ChannelStatus {
this.displayHeartbeatInfo()
}
}

const UPDATE_ALL_AFTER = 6000

const heartbeatsText = document.querySelector('#heartbeats').text
const heartbeats = JSON.parse(heartbeatsText)
let statuses = heartbeats.map(hb => new ChannelStatus(hb))

setInterval(() => {
const urlPath = document.location.pathname
$.get(`${urlPath}/heartbeats`, (newHeartbeats) => {
console.log('Updating all heartbeats')
newHeartbeats.forEach(hb => {
console.log(`Found ${hb.channel}`)
})
statuses.forEach(hb => { hb.displayStatus(false) })
statuses = newHeartbeats.map(hb => new ChannelStatus(hb))
})
}, UPDATE_ALL_AFTER * 1000)
16 changes: 16 additions & 0 deletions src/rubintv/static/js/modules/historical-reset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* global $ */

function historicalReset () {
const $form = $('#historicalReset')
$form.click(function (e) {
e.preventDefault()
$form.find('.done').addClass('hidden')
$form.find('.pending').toggleClass('hidden')
$.post('reload_historical', function () {
$form.find('.pending').toggleClass('hidden')
$form.find('.done').toggleClass('hidden')
})
})
}

historicalReset()
4 changes: 2 additions & 2 deletions src/rubintv/static/js/refresh-daytable.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createTableControlUI, applySelected, loadMetadata } from './modules/tab
]

let meta = loadMetadata()
createTableControlUI(meta, $('.channel-grid-heading'), defaultSelected)
createTableControlUI(meta, $('#table-controls'), defaultSelected)
applySelected(meta, defaultSelected)
const selected = defaultSelected

Expand All @@ -26,7 +26,7 @@ import { createTableControlUI, applySelected, loadMetadata } from './modules/tab
}).done(function () {
meta = loadMetadata()
applySelected(meta, selected)
createTableControlUI(meta, $('.channel-grid-heading'), selected)
createTableControlUI(meta, $('#table-controls'), selected)
}).fail(function () {
console.log("Couldn't reach server")
})
Expand Down
Loading

0 comments on commit f7eac4a

Please sign in to comment.