Compare commits
46 Commits
Author | SHA1 | Date |
---|---|---|
Mark Cotton | cc4d33d91e | |
Mark Cotton | 6891d581a3 | |
Mark Cotton | a7d6911489 | |
Mark Cotton | e2056290c9 | |
Mark Cotton | 316ea642d9 | |
Ubuntu | a91b48d260 | |
Mark Cotton | e4274e3ea2 | |
Mark Cotton | ba7ecb3282 | |
Mark Cotton | 516340f2b4 | |
Mark Cotton | 5d736db324 | |
Mark Cotton | 094b7b754f | |
Mark Cotton | b02ba38eea | |
Mark Cotton | fb10657e60 | |
Ubuntu | a00347c7ee | |
Mark Cotton | e4dbb9c8e1 | |
Ubuntu | 03cc8797bc | |
Ubuntu | 3b9b0795ff | |
Mark Cotton | dff7923892 | |
Mark Cotton | 24b6a36700 | |
Mark Cotton | 2b78898f0b | |
Mark Cotton | 55b3b82983 | |
Mark Cotton | 014b767265 | |
Mark Cotton | 9067a2a6ce | |
Mark Cotton | 939553b4a5 | |
Mark Cotton | 132addb162 | |
Mark Cotton | 28d5a853f3 | |
Mark Cotton | 382a3859cb | |
Mark Cotton | f0a7366f31 | |
Mark Cotton | 3183e109f7 | |
Mark Cotton | 22bec51d81 | |
Mark Cotton | a44090f042 | |
Mark Cotton | 95fbcfd2d4 | |
Mark Cotton | d10789f002 | |
Mark Cotton | cd5db12a2f | |
Mark Cotton | 9a46f72a51 | |
Mark Cotton | dd77dec94a | |
Mark Cotton | 81eb4daca3 | |
Mark Cotton | 53ae5aa8f1 | |
Mark Cotton | 499e7fa836 | |
Mark Cotton | 67817f5094 | |
Mark Cotton | 6a2923801f | |
Mark Cotton | 032563c85d | |
Mark Cotton | 294dc70774 | |
Mark Cotton | f20787b16c | |
Mark Cotton | 1abb81cb4f | |
Mark Cotton | 557cf2c681 |
|
@ -8,3 +8,5 @@ my_settings.py
|
||||||
.ipynb_checkpoints/
|
.ipynb_checkpoints/
|
||||||
.ipynb_checkpoints/*
|
.ipynb_checkpoints/*
|
||||||
flask_session/
|
flask_session/
|
||||||
|
git-version.txt
|
||||||
|
settings.py
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Mark Cotton
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
395
Playground.ipynb
395
Playground.ipynb
|
@ -1,395 +0,0 @@
|
||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"id": "d4582341",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"# EagleEyev3 Playground\n",
|
|
||||||
"\n",
|
|
||||||
"To make this playground work, it is easier to read the `access_token` off the filesystem but you can always run the example server `python server.py` to go thorugh the Oauth2 flow. By default it will save the `access_token` into a file named `.lazy_login`. The module looks for that file and tries reading t"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"id": "8355d241",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"## Import Module"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 1,
|
|
||||||
"id": "1394471a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stderr",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"INFO:root:Using EagleEyev3 version 0.0.4\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"from EagleEyev3 import *\n",
|
|
||||||
"from settings import config\n",
|
|
||||||
"\n",
|
|
||||||
"logging.info(f\"Using EagleEyev3 version {EagleEyev3.__version__}\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 2,
|
|
||||||
"id": "486a2537",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"een = EagleEyev3(config)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"id": "51b8b66e",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"## Adjust Log Level"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 3,
|
|
||||||
"id": "06d91db2",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import logging\n",
|
|
||||||
"logger = logging.getLogger()\n",
|
|
||||||
"#logger.setLevel('DEBUG')\n",
|
|
||||||
"#logger.setLevel('INFO')\n",
|
|
||||||
"logger.setLevel('WARN')\n",
|
|
||||||
"#logger.setLevel('ERROR')\n",
|
|
||||||
"#logger.setLevel('CRITICAL')"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"id": "0311109c-869c-4190-97c1-a6e717a8eeba",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"## Who am I"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 8,
|
|
||||||
"id": "8d40a4e4",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"een.access_token = \"eyJraWQiOiI2ODYxYjBjYS0wZjI2LTExZWQtODYxZC0wMjQyYWMxMjAwMDIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJjYWZlZGVmMiIsImF1ZCI6InZtcy5hcGkiLCJpc3MiOiJ2bXMuYXV0aC52MSIsInZtc19hY2NvdW50IjoiMDAwMjgyMDEiLCJleHAiOjE2OTA0MzM2MDMsImlhdCI6MTY4OTgyODgwMywianRpIjoiMTIzYmYwZmVmMDk0MDFlZmM0MWYxODhjODVhMGY3MTAiLCJjbGllbnRfaWQiOiIzMmRhYzMzMDY0OWI0ODI3OWY5Y2FjYzJiNmY1N2FlNSIsInZtc19jbHVzdGVyIjoiYzAxMiJ9.O3vQ4B8jRmxKIfTUzMWKVhTCecsyG3J5ARR1yrYDjero3cor1cMyVTdaUwn_pe_vWAVL4je3H2NY0nNlMHIBDbVzfIa8LqPzWfawylQN4-258gixeyjWw1vRUf99hrQXEBa4cTUvi_p7bzyARt8EPOnDczfyAx31r5z3Rt6l21kAe0uCuzCZ4W7VhbWLD9tPADBRnKb6fiS_ZA6U9DpMMWXwT1hnEBklbKOUlsi30r9AQNFGVcL_159OTde0K031LHnyRPv3LnRyXzjZpkaY9zsR--1hq_nCgNipdk42R0rePCF-hw-J0oGpj-vAsHqKadkHgy6cKsqMoE1am1kuKQ\""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 11,
|
|
||||||
"id": "543dae39",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"{'success': True,\n",
|
|
||||||
" 'response_http_status': 200,\n",
|
|
||||||
" 'data': {'httpsBaseUrl': {'hostname': 'api.c012.eagleeyenetworks.com',\n",
|
|
||||||
" 'port': 443}}}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 11,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"een.get_base_url(cascade=True)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 12,
|
|
||||||
"id": "e14e2be5-a5f9-4b8c-ae60-76c61cb61b8b",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"'Mark Cotton - mcotton@mcottondesign.com'"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 12,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"f\"{een.current_user['firstName']} {een.current_user['lastName']} - {een.current_user['email']}\""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 13,
|
|
||||||
"id": "4ef47ae2-a010-4b7e-87f6-3dbf0a047e16",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"'eyJraWQiOiI2ODYxYjBjYS0wZjI2LTExZWQtODYxZC0wMjQyYWMxMjAwMDIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJjYWZlZGVmMiIsImF1ZCI6InZtcy5hcGkiLCJpc3MiOiJ2bXMuYXV0aC52MSIsInZtc19hY2NvdW50IjoiMDAwMjgyMDEiLCJleHAiOjE2OTA0MzM2MDMsImlhdCI6MTY4OTgyODgwMywianRpIjoiMTIzYmYwZmVmMDk0MDFlZmM0MWYxODhjODVhMGY3MTAiLCJjbGllbnRfaWQiOiIzMmRhYzMzMDY0OWI0ODI3OWY5Y2FjYzJiNmY1N2FlNSIsInZtc19jbHVzdGVyIjoiYzAxMiJ9.O3vQ4B8jRmxKIfTUzMWKVhTCecsyG3J5ARR1yrYDjero3cor1cMyVTdaUwn_pe_vWAVL4je3H2NY0nNlMHIBDbVzfIa8LqPzWfawylQN4-258gixeyjWw1vRUf99hrQXEBa4cTUvi_p7bzyARt8EPOnDczfyAx31r5z3Rt6l21kAe0uCuzCZ4W7VhbWLD9tPADBRnKb6fiS_ZA6U9DpMMWXwT1hnEBklbKOUlsi30r9AQNFGVcL_159OTde0K031LHnyRPv3LnRyXzjZpkaY9zsR--1hq_nCgNipdk42R0rePCF-hw-J0oGpj-vAsHqKadkHgy6cKsqMoE1am1kuKQ'"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 13,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"een.access_token"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"id": "a22ff6c2",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"## Get Cameras"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 14,
|
|
||||||
"id": "bb457850",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"ret = een.get_list_of_cameras()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 15,
|
|
||||||
"id": "c43f1db1",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"[✅ [1001423e] - ATM & Wine,\n",
|
|
||||||
" ✅ [10090759] - Benny Camera,\n",
|
|
||||||
" ✅ [100d8666] - Cash Register,\n",
|
|
||||||
" ✅ [1002f1d0] - FR test,\n",
|
|
||||||
" ✅ [10012735] - Fuel Dock,\n",
|
|
||||||
" ✅ [100b7b3b] - Max Camera,\n",
|
|
||||||
" ✅ [10009a85] - R2D2,\n",
|
|
||||||
" ✅ [1002584c] - Safe]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 15,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"[i for i in een.cameras if i.is_online()]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 16,
|
|
||||||
"id": "f4c6fe67",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"[]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 16,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"[i for i in een.cameras if not i.is_online()]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "markdown",
|
|
||||||
"id": "8c140aaf-766f-4255-94ef-199d17cbc7a6",
|
|
||||||
"metadata": {},
|
|
||||||
"source": [
|
|
||||||
"## Getting list of Events"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 17,
|
|
||||||
"id": "74e78ee1-33b8-4a88-9d23-cd6281603a5b",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"2023-07-20T00:32:07.243-05:00 2023-07-19T18:32:07.243-05:00\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"print(een.time_now(), een.time_before())"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 18,
|
|
||||||
"id": "c84c30dd-4b7c-415b-8e6f-e77de70d1924",
|
|
||||||
"metadata": {
|
|
||||||
"scrolled": true
|
|
||||||
},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"for i in range(0,4):\n",
|
|
||||||
" ts = een.time_now()\n",
|
|
||||||
"\n",
|
|
||||||
" for cam in een.cameras:\n",
|
|
||||||
" blah = cam.get_list_of_events(end_timestamp=een.time_before(ts=ts, hours=(6*i)), \\\n",
|
|
||||||
" start_timestamp=een.time_before(ts=ts, hours=(6*(i+1))) )"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 19,
|
|
||||||
"id": "13809cc7-9ec2-4e15-9495-e64feaecca6d",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"5"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 19,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"len(een.cameras[2].events['status'])"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 20,
|
|
||||||
"id": "67d4f79b-2b43-4bdb-9068-28ac9d8d921c",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"0"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 20,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"len(een.cameras[0].events['motion'])"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 25,
|
|
||||||
"id": "c7513a23",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"[1008d090] Benny Camera [10090759]\n",
|
|
||||||
"[1008d090] FR test [1002f1d0]\n",
|
|
||||||
"[1008d090] Max Camera [100b7b3b]\n",
|
|
||||||
"[1008d090] R2D2 [10009a85]\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"for cam in [i for i in een.cameras if i.bridge_id]:\n",
|
|
||||||
" print(f\"[{cam.bridge_id}] {cam.name} [{cam.id}]\")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 22,
|
|
||||||
"id": "8be8d503-b46d-4ba7-884e-2c21c3987129",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"FR test - 2023-07-20T01:22:47.515+00:00 - bridgeOffline \n",
|
|
||||||
"FR test - 2023-07-19T23:32:08.348+00:00 - online \n",
|
|
||||||
"R2D2 - 2023-07-19T19:38:42.286+00:00 - bridgeOffline \n",
|
|
||||||
"R2D2 - 2023-07-19T17:32:10.875+00:00 - online \n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"for cam in [i for i in een.cameras if i.bridge_id]:\n",
|
|
||||||
" for i in cam.events['status']:\n",
|
|
||||||
" print(f\"{cam.name} - {i['startTimestamp']} - {i['data'][0]['newStatus']['connectionStatus']} \")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "1cebbb4e-4c4e-4ab0-9627-251eb812b2f1",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "d8a69185-54cc-4c74-9c9b-a807e601be83",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Python 3 (ipykernel)",
|
|
||||||
"language": "python",
|
|
||||||
"name": "python3"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": {
|
|
||||||
"name": "ipython",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"file_extension": ".py",
|
|
||||||
"mimetype": "text/x-python",
|
|
||||||
"name": "python",
|
|
||||||
"nbconvert_exporter": "python",
|
|
||||||
"pygments_lexer": "ipython3",
|
|
||||||
"version": "3.11.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
|
@ -22,6 +22,10 @@ config = {
|
||||||
"server_host": "127.0.0.1",
|
"server_host": "127.0.0.1",
|
||||||
"server_port": "3333",
|
"server_port": "3333",
|
||||||
"server_path": "login_callback",
|
"server_path": "login_callback",
|
||||||
|
|
||||||
|
# preferences
|
||||||
|
"days_of_history": 1,
|
||||||
|
"log_level": "INFO"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
273
app.py
273
app.py
|
@ -15,60 +15,152 @@ from io import BytesIO
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
#logger.setLevel('DEBUG')
|
from functools import wraps
|
||||||
logger.setLevel('INFO')
|
|
||||||
#logger.setLevel('WARN')
|
|
||||||
#logger.setLevel('ERROR')
|
|
||||||
#logger.setLevel('CRITICAL')
|
|
||||||
|
|
||||||
from EagleEyev3 import *
|
from EagleEyev3 import *
|
||||||
from settings import config
|
from settings import config
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = "this needs to be changed to something unique and not checked into git"
|
||||||
|
|
||||||
|
|
||||||
|
# check if it could pull in a config object from settings.py
|
||||||
|
if config:
|
||||||
|
# start checking for keys in config object and set sensible defaults
|
||||||
|
if 'days_of_history' in config:
|
||||||
|
DAYS_OF_HISTORY = config['days_of_history']
|
||||||
|
else:
|
||||||
|
# fallback to a sane default
|
||||||
|
DAYS_OF_HISTORY = 1
|
||||||
|
|
||||||
|
if 'log_level' in config:
|
||||||
|
logger.setLevel(config['log_level'])
|
||||||
|
|
||||||
|
if 'client_secret' in config:
|
||||||
|
SECRET_KEY = config['client_secret']
|
||||||
|
|
||||||
|
else:
|
||||||
|
logging.setLevel(config['INFO'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logging.info(f"Using EagleEyev3 version {EagleEyev3.__version__}")
|
logging.info(f"Using EagleEyev3 version {EagleEyev3.__version__}")
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
app.secret_key = SECRET_KEY
|
||||||
SESSION_TYPE = 'filesystem'
|
SESSION_TYPE = 'filesystem'
|
||||||
|
SESSION_PERMANENT = False
|
||||||
|
PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 7 # one week in seconds
|
||||||
app.config.from_object(__name__)
|
app.config.from_object(__name__)
|
||||||
Session(app)
|
Session(app)
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
|
|
||||||
|
# $ flask routes -s rule
|
||||||
|
# INFO:root:Using EagleEyev3 version 0.0.15
|
||||||
|
# Endpoint Methods Rule
|
||||||
|
# --------------------- ------- -------------------------------
|
||||||
|
# index GET /
|
||||||
|
# accounts GET /accounts
|
||||||
|
# camera_detail GET /camera/<esn>/events
|
||||||
|
# camera_detail GET /camera/<esn>/events/<int:days>
|
||||||
|
# camera_live_preivew GET /camera/<esn>/preview
|
||||||
|
# camera__preivew_image GET /camera/<esn>/preview_image
|
||||||
|
# camera_status_plot GET /camera/<esn>/status_plot
|
||||||
|
# cameras GET /cameras
|
||||||
|
# landing GET /landing
|
||||||
|
# login_callback GET /login_callback
|
||||||
|
# logout GET /logout
|
||||||
|
# static GET /static/<path:filename>
|
||||||
|
# switch_account GET /switch_account
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
logging.debug(session)
|
||||||
|
if 'een' in session and session['een'].access_token:
|
||||||
|
logging.debug(f"@login_required access_token: {session['een'].access_token}")
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
# failed to find a valid session so redirecting to landing page
|
||||||
|
if 'een' in session:
|
||||||
|
logging.debug(f"@login_required access_token: {session['een'].access_token}")
|
||||||
|
else:
|
||||||
|
logging.warn('@login_requried failed to find a valid session')
|
||||||
|
|
||||||
|
return redirect(url_for('landing'))
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/landing')
|
||||||
|
def landing():
|
||||||
|
|
||||||
|
# sometimes you just want to get to the landing page and want to avoid the index redirect logic
|
||||||
if 'een' in session:
|
if 'een' in session:
|
||||||
een = session['een']
|
een = session['een']
|
||||||
else:
|
else:
|
||||||
een = EagleEyev3(config)
|
een = EagleEyev3(config)
|
||||||
session['een'] = een
|
session['een'] = een
|
||||||
|
|
||||||
|
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
||||||
|
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
||||||
|
values = { "login_link": f"{base_url}{path_url}" }
|
||||||
|
return render_template('cover.html', template_values=values)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
|
een = session['een']
|
||||||
|
|
||||||
# using current_user as a proxy for an established valid session
|
# using current_user as a proxy for an established valid session
|
||||||
if een.access_token == None:
|
if een.access_token == None:
|
||||||
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
||||||
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
||||||
return redirect(f"{base_url}{path_url}")
|
values = { "login_link": f"{base_url}{path_url}" }
|
||||||
|
return render_template('cover.html', template_values=values)
|
||||||
|
|
||||||
# call this to check if session is actually valid
|
# call this to check if session is actually valid
|
||||||
check = een.get_current_user()
|
check = een.get_current_user()
|
||||||
if 'success' not in check or check['success'] == False:
|
if 'success' not in check or check['success'] == False:
|
||||||
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
||||||
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
||||||
return redirect(f"{base_url}{path_url}")
|
|
||||||
|
values = { "login_link": f"{base_url}{path_url}" }
|
||||||
|
return render_template('cover.html', template_values=values)
|
||||||
else:
|
else:
|
||||||
logging.info(f"{check['success']} - check get_current_user {een.current_user['email']} {een.access_token}")
|
logging.info(f"{check['success']} - check get_current_user {een.current_user['email']} {een.access_token}")
|
||||||
|
|
||||||
|
|
||||||
#logging.info(een.cameras)
|
|
||||||
|
|
||||||
|
|
||||||
# this call could get expensive
|
|
||||||
een.get_list_of_cameras()
|
een.get_list_of_cameras()
|
||||||
|
een.get_list_of_accounts()
|
||||||
|
|
||||||
#logging.info(een.cameras)
|
if len(een.accounts) > 0 and een.active_account == None:
|
||||||
|
# they need to pick and account
|
||||||
|
|
||||||
|
logging.info(f"redirecting to accounts.html because they don't have an active account { een.active_account }")
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"current_user": een.current_user,
|
"current_user": een.current_user,
|
||||||
"cameras": een.cameras
|
"accounts": een.accounts,
|
||||||
|
"active_account": een.active_account
|
||||||
|
}
|
||||||
|
redirect(url_for('accounts'))
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"current_user": een.current_user,
|
||||||
|
"cameras": een.cameras,
|
||||||
|
"camera_count": len(een.cameras),
|
||||||
|
"camera_count_online": len([i for i in een.cameras if i.is_online()]),
|
||||||
|
"camera_count_offline": len([i for i in een.cameras if i.is_offline()]),
|
||||||
|
"accounts": een.accounts,
|
||||||
|
"active_account": een.active_account
|
||||||
}
|
}
|
||||||
|
|
||||||
return render_template('index.html', template_values=values)
|
return render_template('index.html', template_values=values)
|
||||||
|
@ -76,28 +168,34 @@ def index():
|
||||||
|
|
||||||
@app.route('/login_callback')
|
@app.route('/login_callback')
|
||||||
def login_callback():
|
def login_callback():
|
||||||
|
|
||||||
# This is getting the ?code= querystring value from the HTTP request.
|
# This is getting the ?code= querystring value from the HTTP request.
|
||||||
code = request.args.get('code')
|
code = request.args.get('code')
|
||||||
|
|
||||||
if 'een' in session:
|
# create a new een object and store it in their session
|
||||||
een = session['een']
|
|
||||||
else:
|
|
||||||
een = EagleEyev3(config)
|
een = EagleEyev3(config)
|
||||||
|
session['een'] = een
|
||||||
|
|
||||||
|
|
||||||
if (code):
|
if (code):
|
||||||
# use the include code parameter to complete login process
|
# use the include code parameter to complete login process
|
||||||
oauth_object = een.login_tokens(code)
|
oauth_object = een.login_tokens(code)
|
||||||
|
|
||||||
|
logging.debug(oauth_object)
|
||||||
|
|
||||||
|
|
||||||
|
# let's try resaving this to see if it fixs the missing access_token on first request after login
|
||||||
|
session['een'] = een
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
if 'een' in session:
|
|
||||||
een = session['een']
|
# logout isn't working APIv3 right now so don't wait for the call to fail before logging out
|
||||||
een.logout()
|
# if 'een' in session:
|
||||||
|
# een = session['een']
|
||||||
|
# een.logout()
|
||||||
|
|
||||||
session.pop('een')
|
session.pop('een')
|
||||||
|
|
||||||
|
@ -105,28 +203,68 @@ def logout():
|
||||||
|
|
||||||
|
|
||||||
@app.route('/cameras')
|
@app.route('/cameras')
|
||||||
|
@login_required
|
||||||
def cameras():
|
def cameras():
|
||||||
if 'een' in session:
|
|
||||||
een = session['een']
|
|
||||||
else:
|
|
||||||
een = EagleEyev3(config)
|
|
||||||
|
|
||||||
een.get_list_of_cameras()
|
# get een instance from session, @login_required already checked for it
|
||||||
|
een = session['een']
|
||||||
|
|
||||||
|
logging.debug(een.get_list_of_cameras())
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"current_user": een.current_user,
|
"current_user": een.current_user,
|
||||||
"cameras": een.cameras
|
"cameras": een.cameras,
|
||||||
|
"camera_count": len(een.cameras),
|
||||||
|
"camera_count_online": len([i for i in een.cameras if i.is_online()]),
|
||||||
|
"camera_count_offline": len([i for i in een.cameras if i.is_offline()])
|
||||||
}
|
}
|
||||||
|
|
||||||
return render_template('cameras_partial.html', template_values=values)
|
return render_template('cameras_partial.html', template_values=values)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/camera/<esn>/live_preview')
|
@app.route('/accounts')
|
||||||
def camera_live_preivew(esn=None):
|
@login_required
|
||||||
if 'een' in session:
|
def accounts():
|
||||||
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
|
een = session['een']
|
||||||
|
|
||||||
|
logging.debug(een.get_list_of_accounts())
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"current_user": een.current_user,
|
||||||
|
"accounts": een.accounts,
|
||||||
|
"active_account": een.active_account
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('accounts_partial.html', template_values=values)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/switch_account')
|
||||||
|
@login_required
|
||||||
|
def switch_account():
|
||||||
|
|
||||||
|
# This is getting the ?account= querystring value from the HTTP request.
|
||||||
|
account = request.args.get('account')
|
||||||
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
|
een = session['een']
|
||||||
|
|
||||||
|
if (account):
|
||||||
|
# switch into account
|
||||||
|
logging.info(f"attempting to switch into account {account}")
|
||||||
|
logging.debug(een.login_from_reseller(target_account_id=account))
|
||||||
|
|
||||||
|
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/camera/<esn>/preview_image')
|
||||||
|
@login_required
|
||||||
|
def camera__preivew_image(esn=None):
|
||||||
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
een = session['een']
|
een = session['een']
|
||||||
else:
|
|
||||||
een = EagleEyev3(config)
|
|
||||||
|
|
||||||
camera = een.get_camera_by_id(esn)
|
camera = een.get_camera_by_id(esn)
|
||||||
res = camera.get_live_preview()
|
res = camera.get_live_preview()
|
||||||
|
@ -137,19 +275,14 @@ def camera_live_preivew(esn=None):
|
||||||
return send_file('static/placeholder.png')
|
return send_file('static/placeholder.png')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/camera/<esn>')
|
@app.route('/camera/<esn>/preview')
|
||||||
def camera_detail(esn=None):
|
@login_required
|
||||||
if 'een' in session:
|
def camera_live_preivew(esn=None):
|
||||||
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
een = session['een']
|
een = session['een']
|
||||||
else:
|
|
||||||
een = EagleEyev3(config)
|
|
||||||
|
|
||||||
camera = een.get_camera_by_id(esn)
|
camera = een.get_camera_by_id(esn)
|
||||||
now = een.time_now()
|
|
||||||
|
|
||||||
for i in tqdm(range(0,4)):
|
|
||||||
camera.get_list_of_events(end_timestamp=een.time_before(ts=now, hours=6*i), \
|
|
||||||
start_timestamp=een.time_before(ts=now, hours=6*(i+1)))
|
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"current_user": een.current_user,
|
"current_user": een.current_user,
|
||||||
|
@ -157,27 +290,59 @@ def camera_detail(esn=None):
|
||||||
"events": camera.events['status']
|
"events": camera.events['status']
|
||||||
}
|
}
|
||||||
|
|
||||||
return render_template('camera_detail_partial.html', template_values=values)
|
return render_template('camera_preview.html', template_values=values)
|
||||||
|
|
||||||
|
@app.route("/camera/<esn>/events/<int:days>")
|
||||||
|
@app.route('/camera/<esn>/events')
|
||||||
|
@login_required
|
||||||
|
def camera_detail(esn=None, days=DAYS_OF_HISTORY):
|
||||||
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
|
een = session['een']
|
||||||
|
|
||||||
|
camera = een.get_camera_by_id(esn)
|
||||||
|
|
||||||
|
# because of API limitation, can only query 24 hours max
|
||||||
|
for i in tqdm(range(0, days)):
|
||||||
|
camera.get_list_of_events(end_timestamp=een.time_before(hours=24*i), \
|
||||||
|
start_timestamp=een.time_before(hours=24*(i+1)))
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"current_user": een.current_user,
|
||||||
|
"camera": camera,
|
||||||
|
"events": camera.events['status']
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('camera_events_partial.html', template_values=values)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/camera/<esn>/status_plot')
|
@app.route('/camera/<esn>/status_plot')
|
||||||
|
@login_required
|
||||||
def camera_status_plot(esn=None):
|
def camera_status_plot(esn=None):
|
||||||
if 'een' in session:
|
|
||||||
|
# get een instance from session, @login_required already checked for it
|
||||||
een = session['een']
|
een = session['een']
|
||||||
else:
|
|
||||||
een = EagleEyev3(config)
|
|
||||||
|
|
||||||
cam = een.get_camera_by_id(esn)
|
cam = een.get_camera_by_id(esn)
|
||||||
|
|
||||||
|
logging.debug(cam.events['status'][0])
|
||||||
|
|
||||||
|
if cam.events['status'][0]['endTimestamp'] == None:
|
||||||
|
logging.debug('found empty end_timestamp')
|
||||||
|
cam.events['status'][0]['endTimestamp'] = str(pd.Timestamp.utcnow())
|
||||||
|
|
||||||
atm_df = pd.DataFrame(cam.events['status'][::-1], columns=['id', 'startTimestamp', 'actorId', 'data'])
|
atm_df = pd.DataFrame(cam.events['status'][::-1], columns=['id', 'startTimestamp', 'actorId', 'data'])
|
||||||
atm_df['ts'] = pd.to_datetime(atm_df.startTimestamp)
|
atm_df['ts'] = pd.to_datetime(atm_df.startTimestamp)
|
||||||
atm_df['status_desc'] = atm_df['data'].apply(lambda x: x[0]['newStatus']['connectionStatus'])
|
atm_df['status_desc'] = atm_df['data'].apply(lambda x: x[0]['newStatus']['connectionStatus'])
|
||||||
atm_df['status'] = atm_df['status_desc'].replace(to_replace=['online', 'offline', 'error', 'deviceOffline', 'deviceOnline', 'off', 'bridgeOffline'], value=[1,0,0,0,0,0,0])
|
atm_df['status'] = atm_df['status_desc'].replace(to_replace=['online', 'offline', 'error', 'deviceOffline', 'deviceOnline', 'off', 'bridgeOffline', 'unknown'], value=[1,0,0,0,0,0,0,0])
|
||||||
imp = atm_df.set_index(['ts'])
|
imp = atm_df.set_index(['ts'])
|
||||||
|
|
||||||
imp['startTimestamp'] = pd.to_datetime(imp['startTimestamp'])
|
imp['startTimestamp'] = pd.to_datetime(imp['startTimestamp'])
|
||||||
imp = imp.drop(['id', 'actorId', 'data', 'status_desc'], axis=1)
|
imp = imp.drop(['id', 'actorId', 'data', 'status_desc'], axis=1)
|
||||||
data = imp.resample('S').bfill()
|
imp['status'] = imp['status'].ffill()
|
||||||
data['status'] = data['status'].astype('int64')
|
data = imp.resample('S').ffill()
|
||||||
|
# logging.debug(data.tail(200))
|
||||||
|
# data['status'] = data['status'].astype('int64')
|
||||||
|
|
||||||
data = data.drop(['startTimestamp'], axis=1)
|
data = data.drop(['startTimestamp'], axis=1)
|
||||||
|
|
||||||
|
@ -197,14 +362,18 @@ def camera_status_plot(esn=None):
|
||||||
|
|
||||||
# Save it to a temporary buffer.
|
# Save it to a temporary buffer.
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
fig.savefig(buf, format="png")
|
fig.savefig(buf, format="png", transparent=True)
|
||||||
# Embed the result in the html output.
|
# Embed the result in the html output.
|
||||||
data = base64.b64encode(buf.getbuffer()).decode("ascii")
|
data = base64.b64encode(buf.getbuffer()).decode("ascii")
|
||||||
return f"<div class='col-md-12'><img src='data:image/png;base64,{data}' width=70%/></div>"
|
return f"<h3>Graph of Status Events <i class='bi bi-bar-chart'></i></h3><div class='col-md-12'><img src='data:image/png;base64,{data}' style='max-width:100%'/></div>"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host=een.server_host, port=een.server_port)
|
app.run(host=config.server_host, port=config.server_port)
|
||||||
|
|
|
@ -14,7 +14,7 @@ Werkzeug==2.3.6
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
cachelib==0.10.2
|
cachelib==0.10.2
|
||||||
Flask-Session==0.5.0
|
Flask-Session==0.5.0
|
||||||
EagleEyev3>=0.0.4
|
EagleEyev3>=0.0.18
|
||||||
tqdm
|
tqdm
|
||||||
pandas
|
pandas
|
||||||
numpy
|
numpy
|
||||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
* Globals
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a,
|
||||||
|
a:focus,
|
||||||
|
a:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom default button */
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-secondary:hover,
|
||||||
|
.btn-secondary:focus {
|
||||||
|
color: #333;
|
||||||
|
text-shadow: none; /* Prevent inheritance from `body` */
|
||||||
|
background-color: #fff;
|
||||||
|
border: .05rem solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Base structure
|
||||||
|
*/
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
|
||||||
|
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
max-width: 42em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Header
|
||||||
|
*/
|
||||||
|
.masthead {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead-brand {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .nav-link {
|
||||||
|
padding: .25rem 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, .5);
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom: .25rem solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .nav-link:hover,
|
||||||
|
.nav-masthead .nav-link:focus {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .nav-link + .nav-link {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .active {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
.masthead-brand {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
.nav-masthead {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cover
|
||||||
|
*/
|
||||||
|
.cover {
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
.cover .btn-lg {
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Footer
|
||||||
|
*/
|
||||||
|
.mastfoot {
|
||||||
|
color: rgba(255, 255, 255, .5);
|
||||||
|
}
|
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,27 @@
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4 offset-5">
|
||||||
|
<h2>
|
||||||
|
{% for account in template_values['accounts'] %}
|
||||||
|
{% if account.id == template_values['active_account'] %}
|
||||||
|
{{ account.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label >Choose Account:</label>
|
||||||
|
<select onchange="this.options[this.selectedIndex].value && (window.location = this.options[this.selectedIndex].value);">
|
||||||
|
{% for account in template_values['accounts'] %}
|
||||||
|
<option value="/switch_account?account={{ account.id }}"
|
||||||
|
{% if account.id == template_values['active_account'] %}
|
||||||
|
selected
|
||||||
|
{% endif %}
|
||||||
|
title="{{ account.id }}">
|
||||||
|
{{ account.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -26,6 +26,22 @@
|
||||||
<!-- self-hosted fontawesome -->
|
<!-- self-hosted fontawesome -->
|
||||||
<link href="/static/css/all.css" rel="stylesheet">
|
<link href="/static/css/all.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<link href="/static/theme.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row {
|
||||||
|
border: dashed lightgray 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.left_text {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{% block style %}
|
{% block style %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -43,7 +59,7 @@
|
||||||
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
|
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
|
||||||
<li class="nav-item active">
|
<li class="nav-item active">
|
||||||
<a href="/" class="navbar-brand d-flex align-items-center">
|
<a href="/" class="navbar-brand d-flex align-items-center">
|
||||||
<strong><small>status</small>.mcotton.space</strong>
|
<small>status</small>.mcotton.space
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
|
@ -1,30 +1,3 @@
|
||||||
|
<h3>Preview Image <i class="bi bi-card-image"></i></h3>
|
||||||
|
<h5>{{ template_values['camera'].name }}</h5>
|
||||||
<div class="row">
|
<img src="/camera/{{ template_values['camera'].id }}/live_preview" style="max-height:360px;">
|
||||||
<div class="col-md-8">
|
|
||||||
<h3>{{ template_values['camera'].name }}</h3>
|
|
||||||
<img src="/camera/{{ template_values['camera'].id }}/live_preview" style="width: 100%; max-height:360px;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4" id="events_list">
|
|
||||||
<h3>Events</h3>
|
|
||||||
<ul>
|
|
||||||
{% if template_values['events'] %}
|
|
||||||
{% for event in template_values['events'] %}
|
|
||||||
<li>
|
|
||||||
{{ event['data'][0]['newStatus']['connectionStatus'] }}
|
|
||||||
<br> <small>{{ event['endTimestamp'] }}</small>
|
|
||||||
<br> <small>{{ event['startTimestamp'] }}</small>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<li>No events in the last six hours</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
<div>
|
|
||||||
<button hx-get="/camera/{{ template_values['camera'].id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot">
|
|
||||||
load plot
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
<h3>List of Events <i class="bi bi-calendar-event"></i></h3>
|
||||||
|
<h5>{{ template_values['camera'].name }}</h5>
|
||||||
|
<a href="/camera/{{ template_values['camera'].id }}/events/3" hx-get="/camera/{{ template_values['camera'].id }}/events/3" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
3x<i class="bi bi-calendar-event" title="click to load events list for 3 days"></i>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<a href="/camera/{{ template_values['camera'].id }}/events/7" hx-get="/camera/{{ template_values['camera'].id }}/events/7" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
7x<i class="bi bi-calendar-event" title="click to load events list for 7 days"></i>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<a href="/camera/{{ template_values['camera'].id }}/events/14" hx-get="/camera/{{ template_values['camera'].id }}/events/14" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
14x<i class="bi bi-calendar-event" title="click to load events list for 14 days"></i>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<a href="/camera/{{ template_values['camera'].id }}/status_plot" hx-get="/camera/{{ template_values['camera'].id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
Graph Events <i class="bi bi-bar-chart" title="click to generate graph of events"></i>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
{% if template_values['events'] %}
|
||||||
|
{% for event in template_values['events'] %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2 left_text">
|
||||||
|
<small>Status:</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 offset-1 left_text">
|
||||||
|
<b>{{ event['data'][0]['newStatus']['connectionStatus'] }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 left_text">
|
||||||
|
<small>End:</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 offset-1 left_text">
|
||||||
|
{{ event['endTimestamp'] }}
|
||||||
|
</div>
|
||||||
|
<div class="col-2 left_text">
|
||||||
|
<small>Start:</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 offset-1 left_text">
|
||||||
|
{{ event['startTimestamp'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,3 @@
|
||||||
|
<h3>Preview Image <i class="bi bi-card-image"></i></h3>
|
||||||
|
<h5>{{ template_values['camera'].name }}</h5>
|
||||||
|
<img src="/camera/{{ template_values['camera'].id }}/preview_image" style="max-height:360px; max-width:100%">
|
|
@ -1,41 +1,61 @@
|
||||||
|
|
||||||
|
<h3>Cameras <i class="bi bi-camera-video"></i></h3>
|
||||||
<div class="col-md-3" id="camera_list_items">
|
<h5>Online <small>[{{ template_values['camera_count_online'] }} of {{ template_values['camera_count'] }}]</small></h5>
|
||||||
<h3>Online</h3>
|
|
||||||
<ul>
|
|
||||||
{% for camera in template_values['cameras'] %}
|
{% for camera in template_values['cameras'] %}
|
||||||
{% if camera.is_online() and camera.bridge_id %}
|
{% if camera.is_online() %}
|
||||||
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
|
<div class="row">
|
||||||
{% endif %}
|
<div class="col-sm-5 col-md-6 offset-1 left_text" style="overflow: hidden;">
|
||||||
{% endfor %}
|
<span title="ESN: {{ camera.id }}">{{ camera.name }}</span>
|
||||||
<hr>
|
</div>
|
||||||
{% for camera in template_values['cameras'] %}
|
<div class="col-sm-6 col-md-5">
|
||||||
{% if camera.is_online() and camera.bridge_id == None %}
|
<a href="/camera/{{ camera.id }}/preview" hx-get="/camera/{{ camera.id }}/preview" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
|
||||||
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
|
<button class="btn btn-outline-success">
|
||||||
{% endif %}
|
<i class="bi bi-card-image" title="click to load preview image"></i>
|
||||||
{% endfor %}
|
</button>
|
||||||
</ul>
|
</a>
|
||||||
|
<a href="/camera/{{ camera.id }}/events" hx-get="/camera/{{ camera.id }}/events" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||||
<h3>Offline</h3>
|
<button class="btn btn-outline-success">
|
||||||
<ul>
|
<i class="bi bi-calendar-event" title="click to load events list"></i>
|
||||||
{% for camera in template_values['cameras'] %}
|
</button>
|
||||||
{% if camera.is_offline() and camera.bridge_id %}
|
</a>
|
||||||
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
|
<!-- <a href="/camera/{{ camera.id }}/status_plot" hx-get="/camera/{{ camera.id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
|
||||||
{% endif %}
|
<button class="btn btn-outline-success">
|
||||||
{% endfor %}
|
<i class="bi bi-bar-chart" title="click to generate graph of events"></i>
|
||||||
<hr>
|
</button>
|
||||||
{% for camera in template_values['cameras'] %}
|
</a> -->
|
||||||
{% if camera.is_offline() and camera.bridge_id == None %}
|
|
||||||
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="col-md-6 offset-3">
|
|
||||||
<button hx-get="/cameras" hx-trigger="click" hx-target="#camera_list">refresh</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<div class="col-md-9" id="camera_detail"></div>
|
<h5>Offline <small>[{{ template_values['camera_count_offline'] }} of {{ template_values['camera_count'] }}]</small></h5>
|
||||||
|
{% for camera in template_values['cameras'] %}
|
||||||
|
{% if camera.is_offline() %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-5 col-md-6 offset-1 left_text" style="overflow: hidden;">
|
||||||
|
<span title="ESN: {{ camera.id }} Status: {{ camera.status['connectionStatus'] }}">{{ camera.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-md-5">
|
||||||
|
<a href="/camera/{{ camera.id }}/preview" hx-get="/camera/{{ camera.id }}/preview" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
<i class="bi bi-card-image" title="click to load preview image"></i>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<a href="/camera/{{ camera.id }}/events" hx-get="/camera/{{ camera.id }}/events" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
<i class="bi bi-calendar-event" title="click to load events list"></i>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<!-- <a href="/camera/{{ camera.id }}/status_plot" hx-get="/camera/{{ camera.id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
|
||||||
|
<button class="btn btn-outline-success">
|
||||||
|
<i class="bi bi-bar-chart" title="click to generate graph of events"></i>
|
||||||
|
</button>
|
||||||
|
</a> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
<button hx-get="/cameras" hx-trigger="click" hx-target="#camera_list" class="btn btn-md btn-outline-success" hx-indicator=".progress">refresh <i class="bi bi-arrow-clockwise"></i></button>
|
||||||
|
<br>
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
<link rel="icon" href="/static/favicon.ico">
|
||||||
|
|
||||||
|
<title>status.mcotton.space</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap core CSS -->
|
||||||
|
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Custom styles for this template -->
|
||||||
|
<link href="/static/cover.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="text-center">
|
||||||
|
|
||||||
|
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
|
||||||
|
<header class="masthead mb-auto">
|
||||||
|
<div class="inner">
|
||||||
|
<h3 class="masthead-brand"><a href="/"><small>status</small>.mcotton.space</a></h3>
|
||||||
|
<!-- <nav class="nav nav-masthead justify-content-center">
|
||||||
|
<a class="nav-link active" href="#">Home</a>
|
||||||
|
<a class="nav-link" href="#">Features</a>
|
||||||
|
<a class="nav-link" href="#">Contact</a>
|
||||||
|
</nav>
|
||||||
|
</div> -->
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main role="main" class="inner cover">
|
||||||
|
<h1 class="cover-heading">Camera Status History</h1>
|
||||||
|
<p class="lead">Tired of losing your arms due to offline cameras? Tired of doughnuts being stolen from your breakfast plate? Wish you could see a graph of beeps and boops? Today is your lucky day.</p>
|
||||||
|
<p class="lead">
|
||||||
|
<a href="{{ template_values['login_link'] }}" class="btn btn-lg btn-secondary">Get Started</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<video src="/static/cover_movie_2.mp4" style="max-width:100%;" autoplay controls loop muted preload >
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="mastfoot mt-auto">
|
||||||
|
<div class="inner">
|
||||||
|
<p>This project is open-source and can be found at <a href="https://git.mcotton.space/mcotton/EE-status-v3">EE-status-v3</a> and uses <a href="https://pypi.org/project/EagleEyev3/">EagleEyev3</a>.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Bootstrap core JavaScript
|
||||||
|
================================================== -->
|
||||||
|
<!-- Placed at the end of the document so the pages load faster -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
|
||||||
|
<script>window.jQuery || document.write('<script src="../../assets/js/vendor/jquery-slim.min.js"><\/script>')</script>
|
||||||
|
<script src="../../assets/js/vendor/popper.min.js"></script>
|
||||||
|
<script src="/static/bootstrap.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -12,6 +12,110 @@
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
height: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress .indeterminate:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background-color: inherit;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
will-change: left, right;
|
||||||
|
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
|
||||||
|
infinite;
|
||||||
|
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
|
||||||
|
}
|
||||||
|
.progress .indeterminate:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background-color: inherit;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
will-change: left, right;
|
||||||
|
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
|
||||||
|
infinite;
|
||||||
|
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
|
||||||
|
infinite;
|
||||||
|
-webkit-animation-delay: 1.15s;
|
||||||
|
animation-delay: 1.15s;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.htmx-request .progress {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.htmx-request.progress {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
@-webkit-keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
left: -35%;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
left: 100%;
|
||||||
|
right: -90%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
right: -90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
left: -35%;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
left: 100%;
|
||||||
|
right: -90%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
right: -90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@-webkit-keyframes indeterminate-short {
|
||||||
|
0% {
|
||||||
|
left: -200%;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
left: 107%;
|
||||||
|
right: -8%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 107%;
|
||||||
|
right: -8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes indeterminate-short {
|
||||||
|
0% {
|
||||||
|
left: -200%;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
left: 107%;
|
||||||
|
right: -8%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 107%;
|
||||||
|
right: -8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -22,13 +126,32 @@
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
<div class="container text-center">
|
{% if template_values['accounts']|count > 1 %}
|
||||||
|
{% include 'accounts_partial.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row" id="camera_list">
|
<div class="progress" style="height: 3px; background-color: white;">
|
||||||
{% include 'cameras_partial.html' %}
|
<div class="indeterminate" style="background-color: red;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" id="camera_status_plot">
|
<div class="container text-center">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5" id="camera_list" style="max-height:400px; overflow:auto;">
|
||||||
|
{% include 'cameras_partial.html' %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7" id="camera_detail">
|
||||||
|
<h2>Preview Image <i class="bi bi-card-image"></i></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5" id="camera_status_events" style="max-height:400px; overflow:auto;">
|
||||||
|
<h2>List of Events <i class="bi bi-calendar-event"></i></h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7" id="camera_status_plot">
|
||||||
|
<h2>Graph of Status Events <i class="bi bi-bar-chart"></i></h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue