Skip to content

Commit baf16d5

Browse files
authored
Introduces camera status page (#196)
* docs: Added code of conduct * Merge branch 'local-timezone' into page-camera-status * style: Added various svg for PR * style: Updated CSS new classes & update * config: Added langage params & cam inactivity threshold * feat: Updated to handle multipage with lang as url param * feat: Updated navbar to handle language as url param & pyronear logo button to homepage * style: Updated alert card style * feat: Added api watchers for cameras to handle cameras status * Feat: Added callback to handle language switch * feat: Added camera status page * fix: clean
1 parent 576302f commit baf16d5

12 files changed

+256
-25
lines changed

app/assets/css/style.css

+19-4
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,19 @@ a.no-underline {
5858
text-align: center;
5959
}
6060

61-
.alert-card {
61+
.pyronear-card {
6262
background-color: #FFEBD6;
63-
margin-bottom: 12px;
64-
padding: 8px;
6563
border-radius: 8px;
66-
width: 100%;
6764
border: 0px solid;
6865
box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12);
6966
}
7067

68+
.alert-card {
69+
margin-bottom: 12px;
70+
padding: 8px;
71+
width: 100%;
72+
}
73+
7174
#alert-information-styling-container {
7275
display: flex;
7376
flex-direction: column;
@@ -81,3 +84,15 @@ a.no-underline {
8184
.alert-information-title {
8285
font-weight: bold;
8386
}
87+
88+
.navbar-button{
89+
color: white;
90+
border: 1px solid white;
91+
92+
}
93+
94+
.navbar-button:hover{
95+
color: white;
96+
border: 1px solid white;
97+
background-color: #03383c;
98+
}

app/assets/images/camera.svg

+7
Loading

app/assets/images/clock-error.svg

+7
Loading

app/assets/images/clock.svg

+4
Loading

app/assets/images/no-image.svg

+12
Loading

app/callbacks/data_callbacks.py

+19
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from main import app
1616

1717
import config as cfg
18+
from pages.cameras_status import display_cam_cards
1819
from services import api_client, get_token
1920
from utils.data import process_bbox
2021

@@ -150,6 +151,24 @@ def get_cameras(user_token):
150151
return cameras.to_json(orient="split")
151152

152153

154+
@app.callback(
155+
Output("camera-cards-container", "children"),
156+
[Input("main_api_fetch_interval", "n_intervals"), Input("api_cameras", "data")],
157+
State("user_token", "data"),
158+
)
159+
def api_cameras_watcher(n_intervals, api_cameras, user_token):
160+
161+
logger.info("Get cameras data")
162+
if user_token is not None:
163+
api_client.token = user_token
164+
165+
cameras = pd.DataFrame(api_client.fetch_cameras().json())
166+
cameras["last_active_at"] = pd.to_datetime(cameras["last_active_at"]).dt.strftime("%Y-%m-%d %H:%M")
167+
cameras = cameras.sort_values("name")
168+
169+
return display_cam_cards(cameras)
170+
171+
153172
@app.callback(
154173
Output("api_sequences", "data"),
155174
[Input("main_api_fetch_interval", "n_intervals"), Input("api_cameras", "data")],

app/callbacks/display_callbacks.py

+38
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import ast
77
import json
8+
import urllib
89
from io import StringIO
910

1011
import dash
@@ -21,6 +22,43 @@
2122
logger = logging_config.configure_logging(cfg.DEBUG, cfg.SENTRY_DSN)
2223

2324

25+
@app.callback(Output("camera_status_button_text", "children"), Input("url", "search"))
26+
def update_nav_bar_language(search):
27+
28+
translate = {
29+
"fr": {
30+
"camera_status": "Statut des Caméras",
31+
},
32+
"es": {
33+
"camera_status": "Estado de las Cámaras",
34+
},
35+
}
36+
37+
params = dict(urllib.parse.parse_qsl(search.lstrip("?"))) if search else {}
38+
39+
lang = params.get("lang", cfg.DEFAULT_LANGUAGE)
40+
41+
return [translate[lang]["camera_status"]]
42+
43+
44+
@app.callback(Output("url", "search"), [Input("btn-fr", "n_clicks"), Input("btn-es", "n_clicks")])
45+
def update_language_url(fr_clicks, es_clicks):
46+
# Check which button has been clicked
47+
ctx = dash.callback_context
48+
if not ctx.triggered:
49+
return ""
50+
51+
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
52+
53+
# update the URL according to the button clicked
54+
if button_id == "btn-fr":
55+
return "?lang=fr"
56+
elif button_id == "btn-es":
57+
return "?lang=es"
58+
59+
return ""
60+
61+
2462
# Create event list
2563
@app.callback(
2664
Output("sequence-list-container", "children"),

app/components/navbar.py

+25-4
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,42 @@
1010
pyro_logo = "https://pyronear.org/img/logo_letters_orange.png"
1111

1212

13-
def Navbar():
13+
def Navbar(lang="fr"):
1414
navbar = dbc.Navbar(
1515
[
1616
dbc.Row(
1717
[
18-
dbc.Col(html.Img(src=pyro_logo, height="30px"), width=3),
18+
dbc.Col(
19+
html.A(
20+
html.Img(src=pyro_logo, height="30px"),
21+
href="/",
22+
),
23+
width=3,
24+
),
1925
],
2026
align="center",
2127
),
2228
html.Div(
2329
className="ml-auto",
2430
style={"display": "flex", "flexDirection": "row", "gap": "10px", "marginRight": "10px"},
2531
children=[
26-
dbc.Button(["🇫🇷", " FR"], href="/fr", color="light", className="mr-2"),
27-
dbc.Button(["🇪🇸", " ES"], href="/es", color="light"),
32+
dbc.Button(
33+
html.Div(
34+
[
35+
html.Img(
36+
src="assets/images/camera.svg",
37+
style={"width": "20px", "height": "20px", "marginRight": "5px"},
38+
),
39+
html.P(children=[], style={"margin": "0"}, id="camera_status_button_text"),
40+
],
41+
style={"display": "flex", "alignItems": "center"},
42+
),
43+
href="/cameras-status",
44+
outline=True,
45+
className="navbar-button",
46+
),
47+
dbc.Button(["🇫🇷", " FR"], id="btn-fr", color="light", className="mr-2"),
48+
dbc.Button(["🇪🇸", " ES"], id="btn-es", color="light"),
2849
],
2950
),
3051
],

app/config.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.
55

66
import os
7-
from typing import Optional
7+
from typing import List, Optional
88

99
from dotenv import load_dotenv
1010

@@ -19,6 +19,9 @@
1919
LOGIN: bool = os.environ.get("LOGIN", "true").lower() == "true"
2020
PYRORISK_FALLBACK: str = "https://github.com/pyronear/pyro-risks/releases/download/v0.1.0-data/pyrorisk_20200901.json"
2121
GEOJSON_FILE: str = "https://github.com/pyronear/pyro-risks/releases/download/v0.1.0-data/departements.geojson"
22+
AVAILABLE_LANGS: List[str] = ["fr", "es"]
23+
DEFAULT_LANGUAGE: str = "fr"
24+
CAMERA_INACTIVITY_THRESHOLD_MINUTES: int = 30
2225
# Sentry
2326
SENTRY_DSN: Optional[str] = os.getenv("SENTRY_DSN")
2427
SERVER_NAME: Optional[str] = os.getenv("SERVER_NAME")

app/index.py

+18-15
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# This program is licensed under the Apache License 2.0.
44
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.
55
import argparse
6+
import urllib.parse
67

78
import callbacks.data_callbacks
89
import callbacks.display_callbacks # noqa: F401
@@ -13,6 +14,7 @@
1314
from main import app
1415

1516
import config as cfg
17+
from pages.cameras_status import cameras_status_layout
1618
from pages.homepage import homepage_layout
1719
from pages.login import login_layout
1820

@@ -26,28 +28,29 @@
2628
# Manage Pages
2729
@app.callback(
2830
Output("page-content", "children"),
29-
[Input("url", "pathname"), Input("api_cameras", "data")],
31+
[Input("url", "pathname"), Input("api_cameras", "data"), Input("url", "search")],
3032
State("user_token", "data"),
3133
)
32-
def display_page(pathname, api_cameras, user_token):
34+
def display_page(pathname, api_cameras, search, user_token):
3335
logger.debug(
3436
"display_page called with pathname: %s, user_token: %s",
3537
pathname,
3638
user_token,
3739
)
38-
if user_token is None:
39-
if pathname == "/" or pathname == "/fr" or pathname is None:
40-
logger.info("No user headers found, showing login layout (language: French).")
41-
return login_layout(lang="fr")
42-
if pathname == "/es":
43-
logger.info("No user headers found, showing login layout (language: Spanish).")
44-
return login_layout(lang="es")
45-
if pathname == "/" or pathname == "/fr" or pathname is None:
46-
logger.info("Showing homepage layout (default language: French).")
47-
return homepage_layout(user_token, api_cameras, lang="fr")
48-
if pathname == "/es":
49-
logger.info("Showing homepage layout (language: Spanish).")
50-
return homepage_layout(user_token, api_cameras, lang="es")
40+
41+
params = dict(urllib.parse.parse_qsl(search.lstrip("?"))) if search else {}
42+
43+
lang = params.get("lang", cfg.DEFAULT_LANGUAGE)
44+
45+
if lang not in cfg.AVAILABLE_LANGS:
46+
lang = cfg.DEFAULT_LANGUAGE
47+
48+
if not isinstance(user_token, str) or not user_token:
49+
return login_layout(lang=lang)
50+
if pathname == "/" or pathname is None:
51+
return homepage_layout(user_token, api_cameras, lang=lang)
52+
if pathname == "/cameras-status":
53+
return cameras_status_layout(user_token, api_cameras, lang=lang)
5154
else:
5255
logger.warning("Unable to find page for pathname: %s", pathname)
5356
return html.Div([html.P("Unable to find this page.", className="alert alert-warning")])

app/pages/cameras_status.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (C) 2020-2025, Pyronear.
2+
3+
# This program is licensed under the Apache License 2.0.
4+
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.
5+
6+
from datetime import datetime, timedelta
7+
8+
import dash_bootstrap_components as dbc
9+
from dash import html
10+
11+
import config as cfg
12+
from utils.display import convert_dt_to_local_tz
13+
14+
15+
def display_cam_cards(cameras):
16+
17+
no_cam_img_url = "assets/images/no-image.svg"
18+
19+
cards = []
20+
for _, row in cameras.iterrows():
21+
22+
clock_image_src_issue = "assets/images/clock-error.svg"
23+
last_active_at_style_issue = {"margin": "0", "color": "#f44336"}
24+
25+
if str(row["last_active_at"]) == "nan":
26+
clock_image_src = clock_image_src_issue
27+
last_active_at_style = last_active_at_style_issue
28+
else:
29+
if datetime.now() - datetime.strptime(row["last_active_at"], "%Y-%m-%d %H:%M") > timedelta(
30+
minutes=cfg.CAMERA_INACTIVITY_THRESHOLD_MINUTES
31+
):
32+
clock_image_src = clock_image_src_issue
33+
last_active_at_style = last_active_at_style_issue
34+
else:
35+
clock_image_src = "assets/images/clock.svg"
36+
last_active_at_style = {"margin": "0"}
37+
38+
card = dbc.Col(
39+
dbc.Card(
40+
[
41+
dbc.CardBody(
42+
[
43+
html.H4(row["name"], className="card-title"),
44+
html.Div(
45+
[
46+
html.Img(
47+
src=clock_image_src,
48+
style={"width": "20px", "height": "20px", "marginRight": "5px"},
49+
),
50+
html.P(
51+
f"{convert_dt_to_local_tz(row['lat'], row['lon'], row['last_active_at'])}",
52+
style=last_active_at_style,
53+
),
54+
],
55+
style={"display": "flex", "alignItems": "center"},
56+
),
57+
],
58+
style={"padding": "10px"},
59+
),
60+
dbc.CardImg(
61+
src=row["last_image"] if row["last_image"] is not None else no_cam_img_url,
62+
top=False,
63+
style={"width": "100%", "borderRadius": "0"},
64+
),
65+
],
66+
className="pyronear-card",
67+
),
68+
width=6,
69+
md=3,
70+
xxl=2,
71+
style={"marginTop": "1.5rem"},
72+
)
73+
cards.append(card)
74+
75+
return dbc.Row(cards)
76+
77+
78+
def cameras_status_layout(user_token, api_cameras, lang="fr"):
79+
translate = {
80+
"fr": {
81+
"breadcrumb": "Dashboard des caméras",
82+
"page_title": "Dashboard de l'état des caméras",
83+
},
84+
"es": {
85+
"breadcrumb": "Panel de cámaras",
86+
"page_title": "Panel de control del estado de la cámara",
87+
},
88+
}
89+
90+
return dbc.Container(
91+
[
92+
dbc.Breadcrumb(
93+
items=[
94+
{"label": "Homepage", "href": "/", "external_link": False},
95+
{"label": translate[lang]["breadcrumb"], "active": True},
96+
],
97+
),
98+
html.H1(translate[lang]["page_title"], style={"font-size": "2rem"}),
99+
html.Div(id="camera-cards-container", children=[]),
100+
],
101+
fluid=True,
102+
)

0 commit comments

Comments
 (0)