diff --git a/resources/i18n/pt.json b/resources/i18n/pt.json index e37af877b..aced12a6a 100644 --- a/resources/i18n/pt.json +++ b/resources/i18n/pt.json @@ -291,7 +291,9 @@ "title": "Atividade", "totalScanned": "Total de pastas encontradas", "quickScan": "Scan rápido", - "fullScan": "Scan completo" + "fullScan": "Scan completo", + "serverUptime": "Uptime do servidor", + "serverDown": "DESCONECTADO" }, "player": { "playListsText": "Fila de Execução", diff --git a/server/events/events.go b/server/events/events.go index 8cf15a4d4..12db34b32 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -1,5 +1,7 @@ package events +import "time" + type Event interface { EventName() string } @@ -16,3 +18,9 @@ type KeepAlive struct { } func (s KeepAlive) EventName() string { return "keepAlive" } + +type ServerStart struct { + StartTime time.Time `json:"startTime"` +} + +func (s ServerStart) EventName() string { return "serverStart" } diff --git a/server/events/sse.go b/server/events/sse.go index 665173e21..7d684375c 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -16,6 +16,8 @@ type Broker interface { SendMessage(event Event) } +var serverStart time.Time + type broker struct { // Events are pushed to this channel by the main events-gathering routine notifier chan []byte @@ -46,6 +48,13 @@ func NewBroker() Broker { } func (broker *broker) SendMessage(event Event) { + data := broker.formatEvent(event) + + log.Trace("Broker received new event", "name", event.EventName(), "payload", string(data)) + broker.notifier <- data +} + +func (broker *broker) formatEvent(event Event) []byte { pkg := struct { Event `json:"data"` Name string `json:"name"` @@ -53,9 +62,7 @@ func (broker *broker) SendMessage(event Event) { pkg.Name = event.EventName() pkg.Event = event data, _ := json.Marshal(pkg) - - log.Trace("Broker received new event", "name", pkg.Name, "payload", string(data)) - broker.notifier <- data + return data } func (broker *broker) ServeHTTP(rw http.ResponseWriter, req *http.Request) { @@ -111,27 +118,34 @@ func (broker *broker) listen() { for { select { case s := <-broker.newClients: - // A new client has connected. // Register their message channel broker.clients[s] = true log.Debug("Client added to event broker", "numClients", len(broker.clients)) - case s := <-broker.closingClients: + // Send a serverStart event to new client + s <- broker.formatEvent(&ServerStart{serverStart}) + + case s := <-broker.closingClients: // A client has dettached and we want to // stop sending them messages. delete(broker.clients, s) log.Debug("Removed client from event broker", "numClients", len(broker.clients)) - case event := <-broker.notifier: + case event := <-broker.notifier: // We got a new event from the outside! // Send event to all connected clients for clientMessageChan := range broker.clients { clientMessageChan <- event } + case ts := <-keepAlive.C: // Send a keep alive packet every 15 seconds broker.SendMessage(&KeepAlive{TS: ts.Unix()}) } } } + +func init() { + serverStart = time.Now() +} diff --git a/ui/src/App.js b/ui/src/App.js index 059716e61..2b9df8de9 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -27,7 +27,7 @@ import createAdminStore from './store/createAdminStore' import { i18nProvider } from './i18n' import config from './config' import { startEventStream } from './eventStream' -import { processEvent } from './actions' + const history = createHashHistory() if (config.gaTrackingId) { @@ -60,7 +60,7 @@ const App = () => ( const Admin = (props) => { const dispatch = useDispatch() if (config.devActivityMenu) { - startEventStream((data) => dispatch(processEvent(data))) + startEventStream(dispatch) } return ( diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index aad78d253..0ce45f59d 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -1,6 +1,10 @@ export const EVENT_SCAN_STATUS = 'EVENT_SCAN_STATUS' +export const EVENT_SERVER_START = 'EVENT_SERVER_START' -const actionsMap = { scanStatus: EVENT_SCAN_STATUS } +const actionsMap = { + scanStatus: EVENT_SCAN_STATUS, + serverStart: EVENT_SERVER_START, +} export const processEvent = (data) => { let type = actionsMap[data.name] @@ -11,8 +15,12 @@ export const processEvent = (data) => { } } -export const scanStatusUpdate = (data) => - processEvent({ - name: EVENT_SCAN_STATUS, - data: data, - }) +export const scanStatusUpdate = (data) => ({ + type: EVENT_SCAN_STATUS, + data: data, +}) + +export const serverDown = () => ({ + type: EVENT_SERVER_START, + data: {}, +}) diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index 981f99f97..f16a520cc 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -1,9 +1,13 @@ import { baseUrl } from './utils' import throttle from 'lodash.throttle' +import { processEvent, serverDown } from './actions' let es = null -let onMessageHandler = null -let timeOut = null +let dispatch = null +let timeout = null +const defaultIntervalCheck = 20000 +const errorIntervalCheck = 2000 +let currentIntervalCheck = defaultIntervalCheck const getEventStream = () => { if (es === null) { @@ -15,22 +19,23 @@ const getEventStream = () => { } // Reestablish the event stream after 20 secs of inactivity -const setTimeout = () => { - if (timeOut != null) { - window.clearTimeout(timeOut) +const setTimeout = (value) => { + currentIntervalCheck = value + if (timeout != null) { + window.clearTimeout(timeout) } - timeOut = window.setTimeout(() => { + timeout = window.setTimeout(() => { if (es != null) { es.close() } es = null - startEventStream(onMessageHandler) - }, 20000) + startEventStream(dispatch) + }, currentIntervalCheck) } -export const startEventStream = (messageHandler) => { - onMessageHandler = messageHandler - setTimeout() +export const startEventStream = (dispatchFunc) => { + dispatch = dispatchFunc + setTimeout(currentIntervalCheck) if (!localStorage.getItem('token')) { console.log('Cannot create a unauthenticated EventSource') return @@ -40,11 +45,17 @@ export const startEventStream = (messageHandler) => { (msg) => { const data = JSON.parse(msg.data) if (data.name !== 'keepAlive') { - onMessageHandler(data) + dispatch(processEvent(data)) } - setTimeout() // Reset timeout on every received message + setTimeout(defaultIntervalCheck) // Reset timeout on every received message }, 100, { trailing: true } ) + es.onerror = (e) => { + setTimeout(errorIntervalCheck) + dispatch(serverDown()) + } + + return es } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 11001b253..f63b7509c 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -291,7 +291,9 @@ "title": "Activity", "totalScanned": "Total Folders Scanned", "quickScan": "Quick Scan", - "fullScan": "Full Scan" + "fullScan": "Full Scan", + "serverUptime": "Server Uptime", + "serverDown": "OFFLINE" }, "player": { "playListsText": "Play Queue", diff --git a/ui/src/layout/ActivityPanel.js b/ui/src/layout/ActivityPanel.js index f9d6031ad..bf46df053 100644 --- a/ui/src/layout/ActivityPanel.js +++ b/ui/src/layout/ActivityPanel.js @@ -15,14 +15,18 @@ import { Box, } from '@material-ui/core' import { FiActivity } from 'react-icons/fi' +import { BiError } from 'react-icons/bi' import { VscSync } from 'react-icons/vsc' import { GiMagnifyingGlass } from 'react-icons/gi' import subsonic from '../subsonic' import { scanStatusUpdate } from '../actions' +import { useInterval } from '../common' +import { formatDuration } from '../utils' const useStyles = makeStyles((theme) => ({ wrapper: { position: 'relative', + color: (props) => (props.up ? null : 'orange'), }, progress: { color: theme.palette.primary.light, @@ -36,17 +40,31 @@ const useStyles = makeStyles((theme) => ({ zIndex: 2, }, counterStatus: { - minWidth: '16em', + minWidth: '15em', }, })) +const getUptime = (serverStart) => + formatDuration((Date.now() - serverStart.startTime) / 1000) + +const Uptime = () => { + const serverStart = useSelector((state) => state.activity.serverStart) + const [uptime, setUptime] = useState(getUptime(serverStart)) + useInterval(() => { + setUptime(getUptime(serverStart)) + }, 1000) + return {uptime} +} + const ActivityPanel = () => { - const classes = useStyles() + const serverStart = useSelector((state) => state.activity.serverStart) + const up = serverStart && serverStart.startTime + const classes = useStyles({ up }) const translate = useTranslate() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) - const scanStatus = useSelector((state) => state.activity.scanStatus) const dispatch = useDispatch() + const scanStatus = useSelector((state) => state.activity.scanStatus) const handleMenuOpen = (event) => setAnchorEl(event.currentTarget) const handleMenuClose = () => setAnchorEl(null) @@ -70,7 +88,7 @@ const ActivityPanel = () => { - + {up ? : } @@ -92,6 +110,17 @@ const ActivityPanel = () => { onClose={handleMenuClose} > + + + + {translate('activity.serverUptime')}: + + + {up ? : translate('activity.serverDown')} + + + + diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index da19c194c..12abb98f2 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -1,8 +1,10 @@ -import { EVENT_SCAN_STATUS } from '../actions' +import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions' + +const defaultState = { scanStatus: { scanning: false, count: 0 } } export const activityReducer = ( previousState = { - scanStatus: { scanning: false, count: 0 }, + scanStatus: defaultState, }, payload ) => { @@ -10,6 +12,13 @@ export const activityReducer = ( switch (type) { case EVENT_SCAN_STATUS: return { ...previousState, scanStatus: data } + case EVENT_SERVER_START: + return { + ...previousState, + serverStart: { + startTime: data.startTime && Date.parse(data.startTime), + }, + } default: return previousState }