piholelongtermstats 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/PKG-INFO +10 -8
  2. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/README.md +9 -6
  3. piholelongtermstats-0.2.6/piholelongtermstats/__init__.py +3 -0
  4. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/app.py +181 -40
  5. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/assets/style.css +18 -1
  6. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/db.py +1 -1
  7. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/plot.py +80 -2
  8. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/process.py +1 -1
  9. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/stats.py +1 -1
  10. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats.egg-info/PKG-INFO +10 -8
  11. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats.egg-info/requires.txt +0 -1
  12. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/pyproject.toml +6 -2
  13. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/tests/test_plot.py +40 -0
  14. piholelongtermstats-0.2.4/piholelongtermstats/__init__.py +0 -3
  15. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/LICENSE +0 -0
  16. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/assets/favicon.ico +0 -0
  17. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats/assets/logo_phlts.png +0 -0
  18. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats.egg-info/SOURCES.txt +0 -0
  19. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats.egg-info/dependency_links.txt +0 -0
  20. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats.egg-info/entry_points.txt +0 -0
  21. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/piholelongtermstats.egg-info/top_level.txt +0 -0
  22. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/setup.cfg +0 -0
  23. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/tests/test_db.py +0 -0
  24. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/tests/test_process.py +0 -0
  25. {piholelongtermstats-0.2.4 → piholelongtermstats-0.2.6}/tests/test_stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: piholelongtermstats
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: A dashboard to explore long-term DNS query data from Pi-hole FTL database files.
5
5
  Author-email: Davis Thomas Daniel <davisthomasdaniel@gmail.com>
6
6
  Maintainer-email: Davis Thomas Daniel <davisthomasdaniel@gmail.com>
@@ -22,11 +22,10 @@ Provides-Extra: develop
22
22
  Requires-Dist: pytest==9.0.1; extra == "develop"
23
23
  Requires-Dist: ruff; extra == "develop"
24
24
  Requires-Dist: build==1.3.0; extra == "develop"
25
- Requires-Dist: twine==6.2.0; extra == "develop"
26
25
  Requires-Dist: pytest-cov==7.0.0; extra == "develop"
27
26
  Dynamic: license-file
28
27
 
29
- # Pi Hole Long Term Statistics v.0.2.4
28
+ # Pi Hole Long Term Statistics v.0.2.6
30
29
 
31
30
  <div align="center">
32
31
 
@@ -74,11 +73,13 @@ See more [screenshots](https://github.com/davistdaniel/PiHoleLongTermStats/blob/
74
73
  > Using your actual Pi-hole FTL db file for querying is **not** recommended and it is advised to use a copy. Place the copy in the project root or specify its path using the `--db_path` argument or `PIHOLE_LT_STATS_DB_PATH` environment variable. In any case, PiHoleLongTermStats does not monitor for changes in the Pi-hole FTL db file even if you mount it.
75
74
 
76
75
  > [!TIP]
77
- > * You can set up a cron job to periodically copy the FTL database to the `db_path` `PIHOLE_LT_STATS_DB_PATH` location, ensuring your stats are updated without touching the live database. Use the reload button in the dashboard to refresh the stats.
76
+ > * You can set up a cron job to periodically copy the FTL database to the `db_path` `PIHOLE_LT_STATS_DB_PATH` location, ensuring your stats are updated without touching the actual database. Use the reload button in the dashboard to refresh the stats.
78
77
  > * Set your timezone (e.g "Europe/Berlin") using `--timezone` or `PIHOLE_LT_STATS_TIMEZONE`.
79
78
 
80
79
  > [!IMPORTANT]
81
- > * PiHoleLongTermStats reloads the Pi-hole FTL database and recalculates stats whenever the dashboard is refreshed or the reload button is clicked.
80
+ > * PiHoleLongTermStats reloads the Pi-hole FTL database and recalculates stats whenever the the reload button is clicked.
81
+ > * Refreshing the page uing the browser's refresh button, will always serve cached dashboard and data.
82
+ > * To update the dashboard with newer data from the database, you have to **manually** click the reload button (🔄) in the dashboard. Alternatively, you can restart the script or the docker container (using a cron job, for instance).
82
83
  > * If no date range is selected, it uses the default period set by `--days` or `PIHOLE_LT_STATS_DAYS`.
83
84
  > * Large date ranges may lead to increased memory usage.
84
85
  > * When multiple database files are provided, PiHoleLongTermStats concatenates them into a single dataframe and sorts the combined data by timestamp. Duplicate entries are **not** removed for calculating stats. Consolidating multiple databases can lead to increased memory usage.
@@ -104,7 +105,7 @@ If you have a copy of your `pihole-FTL.db` file, you can quickly run the dashboa
104
105
 
105
106
  ```bash
106
107
  # Example: Copy from the default Pi-hole location
107
- sudo cp /etc/pihole/pihole-FTL.db .
108
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
108
109
  # Ensure the user running the app has read permissions
109
110
  sudo chown $USER:$USER pihole-FTL.db
110
111
  ```
@@ -176,7 +177,7 @@ If installing using python, you can install from this git repo or as a package f
176
177
 
177
178
  ```bash
178
179
  # Example: Copy from the default Pi-hole location
179
- sudo cp /etc/pihole/pihole-FTL.db .
180
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
180
181
  # Ensure the user running the app has read permissions
181
182
  sudo chown $USER:$USER pihole-FTL.db
182
183
  ```
@@ -231,7 +232,7 @@ If installing using python, you can install from this git repo or as a package f
231
232
  3. Make a copy/backup of your `pihole-FTL.db` (**Important!**) and place it in the PiHoleLongTermStats directory:
232
233
  ```bash
233
234
  # Example: Copy from the default Pi-hole location
234
- sudo cp /etc/pihole/pihole-FTL.db .
235
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
235
236
  # Ensure the user running the app has read permissions
236
237
  sudo chown $USER:$USER pihole-FTL.db
237
238
  ```
@@ -285,6 +286,7 @@ You can configure the application using command-line arguments or environment va
285
286
  ## 🧑‍💻 Contributing
286
287
 
287
288
  Any contribution, feature ideas or bug fixes are always welcome.
289
+ In case of new features, opening an issue and discussion before implementation is recommended.
288
290
  This project uses uv for dependency management.
289
291
 
290
292
  ### Development workflow
@@ -1,4 +1,4 @@
1
- # Pi Hole Long Term Statistics v.0.2.4
1
+ # Pi Hole Long Term Statistics v.0.2.6
2
2
 
3
3
  <div align="center">
4
4
 
@@ -46,11 +46,13 @@ See more [screenshots](https://github.com/davistdaniel/PiHoleLongTermStats/blob/
46
46
  > Using your actual Pi-hole FTL db file for querying is **not** recommended and it is advised to use a copy. Place the copy in the project root or specify its path using the `--db_path` argument or `PIHOLE_LT_STATS_DB_PATH` environment variable. In any case, PiHoleLongTermStats does not monitor for changes in the Pi-hole FTL db file even if you mount it.
47
47
 
48
48
  > [!TIP]
49
- > * You can set up a cron job to periodically copy the FTL database to the `db_path` `PIHOLE_LT_STATS_DB_PATH` location, ensuring your stats are updated without touching the live database. Use the reload button in the dashboard to refresh the stats.
49
+ > * You can set up a cron job to periodically copy the FTL database to the `db_path` `PIHOLE_LT_STATS_DB_PATH` location, ensuring your stats are updated without touching the actual database. Use the reload button in the dashboard to refresh the stats.
50
50
  > * Set your timezone (e.g "Europe/Berlin") using `--timezone` or `PIHOLE_LT_STATS_TIMEZONE`.
51
51
 
52
52
  > [!IMPORTANT]
53
- > * PiHoleLongTermStats reloads the Pi-hole FTL database and recalculates stats whenever the dashboard is refreshed or the reload button is clicked.
53
+ > * PiHoleLongTermStats reloads the Pi-hole FTL database and recalculates stats whenever the the reload button is clicked.
54
+ > * Refreshing the page uing the browser's refresh button, will always serve cached dashboard and data.
55
+ > * To update the dashboard with newer data from the database, you have to **manually** click the reload button (🔄) in the dashboard. Alternatively, you can restart the script or the docker container (using a cron job, for instance).
54
56
  > * If no date range is selected, it uses the default period set by `--days` or `PIHOLE_LT_STATS_DAYS`.
55
57
  > * Large date ranges may lead to increased memory usage.
56
58
  > * When multiple database files are provided, PiHoleLongTermStats concatenates them into a single dataframe and sorts the combined data by timestamp. Duplicate entries are **not** removed for calculating stats. Consolidating multiple databases can lead to increased memory usage.
@@ -76,7 +78,7 @@ If you have a copy of your `pihole-FTL.db` file, you can quickly run the dashboa
76
78
 
77
79
  ```bash
78
80
  # Example: Copy from the default Pi-hole location
79
- sudo cp /etc/pihole/pihole-FTL.db .
81
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
80
82
  # Ensure the user running the app has read permissions
81
83
  sudo chown $USER:$USER pihole-FTL.db
82
84
  ```
@@ -148,7 +150,7 @@ If installing using python, you can install from this git repo or as a package f
148
150
 
149
151
  ```bash
150
152
  # Example: Copy from the default Pi-hole location
151
- sudo cp /etc/pihole/pihole-FTL.db .
153
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
152
154
  # Ensure the user running the app has read permissions
153
155
  sudo chown $USER:$USER pihole-FTL.db
154
156
  ```
@@ -203,7 +205,7 @@ If installing using python, you can install from this git repo or as a package f
203
205
  3. Make a copy/backup of your `pihole-FTL.db` (**Important!**) and place it in the PiHoleLongTermStats directory:
204
206
  ```bash
205
207
  # Example: Copy from the default Pi-hole location
206
- sudo cp /etc/pihole/pihole-FTL.db .
208
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
207
209
  # Ensure the user running the app has read permissions
208
210
  sudo chown $USER:$USER pihole-FTL.db
209
211
  ```
@@ -257,6 +259,7 @@ You can configure the application using command-line arguments or environment va
257
259
  ## 🧑‍💻 Contributing
258
260
 
259
261
  Any contribution, feature ideas or bug fixes are always welcome.
262
+ In case of new features, opening an issue and discussion before implementation is recommended.
260
263
  This project uses uv for dependency management.
261
264
 
262
265
  ### Development workflow
@@ -0,0 +1,3 @@
1
+ ## Author : Davis T. Daniel
2
+ ## PiHoleLongTermStats v.0.2.5
3
+ ## License : MIT
@@ -1,7 +1,8 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.4
2
+ ## PiHoleLongTermStats v.0.2.6
3
3
  ## License : MIT
4
4
 
5
+
5
6
  import os
6
7
  import gc
7
8
  import argparse
@@ -11,6 +12,7 @@ import plotly.express as px
11
12
  import pandas as pd
12
13
  from dash import Dash, dcc, html, Input, Output, State
13
14
  from zoneinfo import ZoneInfo
15
+ from datetime import datetime
14
16
 
15
17
  from piholelongtermstats.db import read_pihole_ftl_db, connect_to_sql, probe_sample_df
16
18
  from piholelongtermstats.process import (
@@ -23,9 +25,11 @@ from piholelongtermstats.plot import (
23
25
  generate_plot_data,
24
26
  generate_client_activity_over_time,
25
27
  generate_queries_over_time,
28
+ generate_top_blocked_domains,
29
+ generate_top_allowed_domains,
26
30
  )
27
31
 
28
- __version__ = "0.2.4"
32
+ __version__ = "0.2.6"
29
33
 
30
34
  # logging setup
31
35
  logging.basicConfig(
@@ -169,6 +173,8 @@ def serve_layout(
169
173
  f"Removed domains matching the regex pattern : {pattern}, number of rows in dataframe : {len(df)}"
170
174
  )
171
175
 
176
+ last_refresh_time = datetime.now(ZoneInfo(timezone)).strftime("%d-%m-%Y (%H:%M)")
177
+
172
178
  # should reduce some memory consumption
173
179
  df["id"] = df["id"].astype("int32")
174
180
  df["type"] = df["type"].astype("int8")
@@ -189,6 +195,10 @@ def serve_layout(
189
195
  callback_data = {
190
196
  "hourly_agg": hourly_data["hourly_agg"],
191
197
  "top_clients": hourly_data["top_clients"],
198
+ "blocked_df": plot_data["blocked_df"],
199
+ "allowed_df": plot_data["allowed_df"],
200
+ "blocked_df_by_client": plot_data["blocked_df_by_client"],
201
+ "allowed_df_by_client": plot_data["allowed_df_by_client"],
192
202
  "data_span_days": plot_data["data_span_days"],
193
203
  }
194
204
 
@@ -197,7 +207,6 @@ def serve_layout(
197
207
  gc.collect()
198
208
 
199
209
  # generate initial plots
200
-
201
210
  initial_filtered_fig = generate_queries_over_time(
202
211
  callback_data=callback_data, client=None
203
212
  )
@@ -249,11 +258,19 @@ def serve_layout(
249
258
  ),
250
259
  html.Br(),
251
260
  html.H6(
252
- f"Timezone is {timezone}. Database records begin on {stats['oldest_data_point']} and end on {stats['latest_data_point']}."
261
+ f"Timezone is {timezone}. Database records begin on {stats['oldest_data_point']} and end on {stats['latest_data_point']}. The dashboard was last reloaded on {last_refresh_time}."
253
262
  ),
254
263
  ],
255
264
  className="sub-heading-card",
256
265
  ),
266
+ html.Div(
267
+ [
268
+ html.Hr(),
269
+ html.Span("Info Cards"),
270
+ html.Hr(),
271
+ ],
272
+ className="section-divider",
273
+ ),
257
274
  # info cards
258
275
  html.Div(
259
276
  [
@@ -631,12 +648,12 @@ def serve_layout(
631
648
  html.H3("Longest Idle Period"),
632
649
  html.P(
633
650
  f"{stats['max_idle_ms']:,.0f} s"
634
- if stats['max_idle_ms'] is not None
651
+ if stats["max_idle_ms"] is not None
635
652
  else "N/A"
636
653
  ),
637
654
  html.P(
638
655
  f"Between {stats['before_gap']} and {stats['after_gap']}"
639
- if stats['before_gap'] and stats['after_gap']
656
+ if stats["before_gap"] and stats["after_gap"]
640
657
  else "N/A",
641
658
  style={"fontSize": "14px", "color": "#777"},
642
659
  ),
@@ -659,7 +676,7 @@ def serve_layout(
659
676
  ),
660
677
  html.P(
661
678
  f"Avg reply time: {stats['slowest_avg_reply_time'] * 1000:.2f} ms"
662
- if stats['slowest_avg_reply_time']
679
+ if stats["slowest_avg_reply_time"]
663
680
  else "N/A",
664
681
  style={"fontSize": "14px", "color": "#777"},
665
682
  ),
@@ -704,32 +721,63 @@ def serve_layout(
704
721
  ],
705
722
  className="kpi-container",
706
723
  ),
724
+ html.Div(
725
+ [
726
+ html.Hr(),
727
+ html.Span("Queries and domains"),
728
+ html.Hr(),
729
+ ],
730
+ className="section-divider",
731
+ ),
707
732
  html.Br(),
708
733
  html.Div(
709
734
  [
710
- html.H2("Queries over time"),
711
- html.H5("Queries from all clients. The data is aggregated hourly."),
712
- dcc.Dropdown(
713
- options=[
714
- {"label": c, "value": c} for c in plot_data["client_list"]
735
+ html.Div(
736
+ [
737
+ dcc.Dropdown(
738
+ options=[
739
+ {"label": c, "value": c}
740
+ for c in plot_data["client_list"]
741
+ ],
742
+ id="client-filter",
743
+ placeholder="Select a Client",
744
+ ),
715
745
  ],
716
- id="client-filter",
717
- placeholder="Select a Client",
746
+ className="cardplot",
718
747
  ),
719
- dcc.Graph(id="filtered-view", figure=initial_filtered_fig),
720
- html.H2("Client Activity Over Time"),
721
- html.H5(
722
- "Client acitivity for all clients. The data is aggregated hourly."
748
+ html.Br(),
749
+ html.Div(
750
+ [
751
+ html.H2("Queries over time"),
752
+ html.H5(
753
+ "Queries from all or selected clients. The data is aggregated hourly."
754
+ ),
755
+ dcc.Graph(id="filtered-view", figure=initial_filtered_fig),
756
+ ],
757
+ className="cardplot",
723
758
  ),
724
- dcc.Graph(id="client-activity-view", figure=initial_activity_fig),
725
- ],
726
- className="cardplot",
759
+ html.Br(),
760
+ html.Div(
761
+ [
762
+ html.H2("Client Activity Over Time"),
763
+ html.H5(
764
+ "Client acitivity for all or selected clients. The data is aggregated hourly."
765
+ ),
766
+ dcc.Graph(
767
+ id="client-activity-view", figure=initial_activity_fig
768
+ ),
769
+ ],
770
+ className="cardplot",
771
+ ),
772
+ ]
727
773
  ),
728
774
  html.Br(),
729
775
  html.Div(
730
776
  [
731
777
  html.H2("Top Blocked Domains"),
732
- html.H5(f"Top {args.n_domains} blocked domains."),
778
+ html.H5(
779
+ f"Top {args.n_domains} blocked domains. Client filter (see above) can be used to select a specific client."
780
+ ),
733
781
  dcc.Graph(
734
782
  id="top-blocked-domains",
735
783
  figure=px.bar(
@@ -755,10 +803,13 @@ def serve_layout(
755
803
  ],
756
804
  className="cardplot",
757
805
  ),
806
+ html.Br(),
758
807
  html.Div(
759
808
  [
760
809
  html.H2("Top Allowed Domains"),
761
- html.H5(f"Top {args.n_domains} allowed domains."),
810
+ html.H5(
811
+ f"Top {args.n_domains} allowed domains. Client filter (see above) can be used to select a specific client."
812
+ ),
762
813
  dcc.Graph(
763
814
  id="top-allowed-domains",
764
815
  figure=px.bar(
@@ -785,6 +836,15 @@ def serve_layout(
785
836
  className="cardplot",
786
837
  ),
787
838
  html.Br(),
839
+ html.Div(
840
+ [
841
+ html.Hr(),
842
+ html.Span("Activity"),
843
+ html.Hr(),
844
+ ],
845
+ className="section-divider",
846
+ ),
847
+ html.Br(),
788
848
  html.Div(
789
849
  [
790
850
  html.H2("Top Client Activity"),
@@ -945,6 +1005,15 @@ def serve_layout(
945
1005
  className="cardplot",
946
1006
  ),
947
1007
  html.Br(),
1008
+ html.Div(
1009
+ [
1010
+ html.Hr(),
1011
+ html.Span("Reply times"),
1012
+ html.Hr(),
1013
+ ],
1014
+ className="section-divider",
1015
+ ),
1016
+ html.Br(),
948
1017
  html.Div(
949
1018
  [
950
1019
  html.Div(
@@ -972,6 +1041,34 @@ def serve_layout(
972
1041
  ]
973
1042
  ),
974
1043
  html.Br(),
1044
+ html.Div(
1045
+ [
1046
+ html.Div(
1047
+ [
1048
+ html.H2("Reply Time Distribution"),
1049
+ html.H5("Distribution of reply times in milliseconds."),
1050
+ dcc.Graph(
1051
+ id="reply-time-histogram",
1052
+ figure=px.histogram(
1053
+ plot_data["reply_time_df"],
1054
+ x="reply_time_ms",
1055
+ labels={
1056
+ "reply_time_ms": "Reply Time (ms)",
1057
+ },
1058
+ color_discrete_sequence=["#3b82f6"],
1059
+ template="plotly_white",
1060
+ ).update_layout(
1061
+ xaxis_title="Reply Time (ms)",
1062
+ yaxis_title="Count",
1063
+ bargap=0.05,
1064
+ ),
1065
+ ),
1066
+ ],
1067
+ className="cardplot",
1068
+ )
1069
+ ]
1070
+ ),
1071
+ html.Br(),
975
1072
  html.Footer(
976
1073
  f"PiHoleLongTermStats v.{__version__}",
977
1074
  style={"textAlign": "center", "padding": "10px", "color": "#666"},
@@ -1032,22 +1129,37 @@ PHLTS_CALLBACK_DATA, initial_layout = serve_layout(
1032
1129
 
1033
1130
  logging.info("Setting initial layout...")
1034
1131
 
1035
- app.layout = html.Div(
1036
- [
1037
- dcc.Loading(
1038
- id="loading-main",
1039
- type="graph",
1040
- fullscreen=True,
1041
- children=[
1042
- html.Div(
1043
- id="page-container",
1044
- children=initial_layout.children,
1045
- className="container",
1046
- )
1047
- ],
1048
- )
1049
- ]
1050
- )
1132
+ # Holds the most recently generated page-container children
1133
+ PHLTS_PAGE_CONTAINER_CHILDREN = initial_layout.children
1134
+
1135
+
1136
+ def serve_app_layout():
1137
+ """Returns the current app layout.
1138
+
1139
+ Dash calls this function on every full page load/browser refresh (since
1140
+ app.layout is set to a function rather than a static object), so it
1141
+ always reflects the latest data instead of only what existed when the
1142
+ container started.
1143
+ """
1144
+ return html.Div(
1145
+ [
1146
+ dcc.Loading(
1147
+ id="loading-main",
1148
+ type="graph",
1149
+ fullscreen=True,
1150
+ children=[
1151
+ html.Div(
1152
+ id="page-container",
1153
+ children=PHLTS_PAGE_CONTAINER_CHILDREN,
1154
+ className="container",
1155
+ )
1156
+ ],
1157
+ )
1158
+ ]
1159
+ )
1160
+
1161
+
1162
+ app.layout = serve_app_layout
1051
1163
 
1052
1164
  del initial_layout
1053
1165
  gc.collect()
@@ -1061,7 +1173,7 @@ gc.collect()
1061
1173
  prevent_initial_call=True,
1062
1174
  )
1063
1175
  def reload_page(n_clicks, start_date, end_date):
1064
- global PHLTS_CALLBACK_DATA
1176
+ global PHLTS_CALLBACK_DATA, PHLTS_PAGE_CONTAINER_CHILDREN
1065
1177
 
1066
1178
  logging.info(f"Reload button clicked. Selected date range: {start_date, end_date}")
1067
1179
 
@@ -1101,6 +1213,10 @@ def reload_page(n_clicks, start_date, end_date):
1101
1213
  ignore_domains=args.ignore_domains,
1102
1214
  )
1103
1215
 
1216
+ # Persist the new layout so that a full browser refresh (which calls
1217
+ # serve_app_layout() again) also sees the reloaded data
1218
+ PHLTS_PAGE_CONTAINER_CHILDREN = layout.children
1219
+
1104
1220
  return layout.children
1105
1221
 
1106
1222
 
@@ -1136,6 +1252,31 @@ def update_client_activity(client, n_clicks):
1136
1252
  return fig
1137
1253
 
1138
1254
 
1255
+ @app.callback(
1256
+ Output("top-blocked-domains", "figure"),
1257
+ Output("top-allowed-domains", "figure"),
1258
+ Input("client-filter", "value"),
1259
+ Input("reload-button", "n_clicks"),
1260
+ prevent_initial_call=True,
1261
+ )
1262
+ def update_top_domains(client, n_clicks):
1263
+ logging.info("Updating top blocked and allowed domain plots...")
1264
+ global PHLTS_CALLBACK_DATA
1265
+
1266
+ blocked_fig = generate_top_blocked_domains(
1267
+ callback_data=PHLTS_CALLBACK_DATA,
1268
+ n_domains=args.n_domains,
1269
+ client=client,
1270
+ )
1271
+ allowed_fig = generate_top_allowed_domains(
1272
+ callback_data=PHLTS_CALLBACK_DATA,
1273
+ n_domains=args.n_domains,
1274
+ client=client,
1275
+ )
1276
+
1277
+ return blocked_fig, allowed_fig
1278
+
1279
+
1139
1280
  def run():
1140
1281
  app.run(host="0.0.0.0", port=args.port, debug=False)
1141
1282
 
@@ -146,7 +146,6 @@ h3 {
146
146
  }
147
147
 
148
148
 
149
-
150
149
  .cardactivity {
151
150
  background: linear-gradient(135deg, #ffffff, #f5f5f0);
152
151
  border-radius: 15px;
@@ -344,4 +343,22 @@ h3 {
344
343
  .date-picker-btn .dash-datepicker-caret-icon {
345
344
  fill: #f7f9fb !important;
346
345
  flex-shrink: 0;
346
+ }
347
+
348
+ .section-divider {
349
+ display: flex;
350
+ align-items: center;
351
+ margin: 20px 0;
352
+ }
353
+
354
+ .section-divider hr {
355
+ flex: 1;
356
+ border: none;
357
+ border-top: 1px solid #999;
358
+ }
359
+
360
+ .section-divider span {
361
+ padding: 0 10px;
362
+ font-weight: bold;
363
+ color: #444;
347
364
  }
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.4
2
+ ## PiHoleLongTermStats v.0.2.6
3
3
  ## License : MIT
4
4
 
5
5
  import sqlite3
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.4
2
+ ## PiHoleLongTermStats v.0.2.6
3
3
  ## License : MIT
4
4
 
5
5
  import logging
@@ -8,7 +8,6 @@ import gc
8
8
  import plotly.express as px
9
9
  import itertools
10
10
 
11
-
12
11
  def generate_plot_data(df, n_clients, n_domains):
13
12
  """Generate plot data for dashboard visualizations.
14
13
 
@@ -71,6 +70,22 @@ def generate_plot_data(df, n_clients, n_domains):
71
70
  .rename(columns={"domain": "Domain", "count": "count"})
72
71
  )
73
72
 
73
+ blocked_df_by_client = (
74
+ tmp_blocked.groupby(["client", "domain"]).size().reset_index(name="count")
75
+ )
76
+ blocked_df_by_client["Domain"] = blocked_df_by_client["domain"].apply(shorten)
77
+ blocked_df_by_client = blocked_df_by_client[
78
+ ["client", "Domain", "count"]
79
+ ].sort_values(["client", "count"], ascending=[True, False])
80
+
81
+ allowed_df_by_client = (
82
+ tmp_allowed.groupby(["client", "domain"]).size().reset_index(name="count")
83
+ )
84
+ allowed_df_by_client["Domain"] = allowed_df_by_client["domain"].apply(shorten)
85
+ allowed_df_by_client = allowed_df_by_client[
86
+ ["client", "Domain", "count"]
87
+ ].sort_values(["client", "count"], ascending=[True, False])
88
+
74
89
  logging.info("Generated plot data for allowed and blocked domains.")
75
90
 
76
91
  # plot data for reply time over days
@@ -152,6 +167,8 @@ def generate_plot_data(df, n_clients, n_domains):
152
167
  "top_clients_stacked": top_clients_stacked,
153
168
  "blocked_df": blocked_df,
154
169
  "allowed_df": allowed_df,
170
+ "blocked_df_by_client": blocked_df_by_client,
171
+ "allowed_df_by_client": allowed_df_by_client,
155
172
  "reply_time_df": reply_time_df,
156
173
  "client_list": client_list,
157
174
  "data_span_days": data_span_days,
@@ -362,3 +379,64 @@ def generate_client_activity_over_time(callback_data, n_clients, client=None):
362
379
  gc.collect()
363
380
 
364
381
  return fig
382
+
383
+ def generate_top_blocked_domains(callback_data, n_domains, client=None):
384
+ logging.info("Generating top blocked domains plot...")
385
+ if client is not None:
386
+ dff = callback_data.get("blocked_df_by_client", pd.DataFrame()).copy()
387
+ dff = dff[dff["client"] == client]
388
+ dff = dff.nlargest(n_domains, "count").reset_index(drop=True)
389
+ title_txt = f"Domains for {client}"
390
+ else:
391
+ dff = callback_data.get("blocked_df", pd.DataFrame()).copy()
392
+ title_txt = ""
393
+
394
+ return _build_top_domain_figure(dff, title_txt, "#ef4444")
395
+
396
+
397
+ def generate_top_allowed_domains(callback_data, n_domains, client=None):
398
+ logging.info("Generating top allowed domains plot...")
399
+ if client is not None:
400
+ dff = callback_data.get("allowed_df_by_client", pd.DataFrame()).copy()
401
+ dff = dff[dff["client"] == client]
402
+ dff = dff.nlargest(n_domains, "count").reset_index(drop=True)
403
+ title_txt = f"Domains for {client}"
404
+ else:
405
+ dff = callback_data.get("allowed_df", pd.DataFrame()).copy()
406
+ title_txt = ""
407
+
408
+ return _build_top_domain_figure(dff, title_txt, "#10b981")
409
+
410
+ def _build_top_domain_figure(domain_df, title_txt, color):
411
+ if domain_df is None or domain_df.empty:
412
+ fig = px.bar(
413
+ pd.DataFrame({"Domain": [], "count": []}),
414
+ x="Domain",
415
+ y="count",
416
+ template="plotly_white",
417
+ )
418
+ fig.update_layout(
419
+ title=title_txt,
420
+ title_font_size=16,
421
+ showlegend=False,
422
+ margin=dict(r=0, t=50, l=0, b=0),
423
+ xaxis=dict(title=None, automargin=True, tickmode="auto"),
424
+ )
425
+ return fig
426
+
427
+ fig = px.bar(
428
+ domain_df,
429
+ y="count",
430
+ x="Domain",
431
+ labels={"Domain": "Domain", "count": "Count"},
432
+ template="plotly_white",
433
+ color_discrete_sequence=[color],
434
+ )
435
+ fig.update_layout(
436
+ title=title_txt,
437
+ title_font_size=16,
438
+ showlegend=False,
439
+ margin=dict(r=0, t=50, l=0, b=0),
440
+ xaxis=dict(title=None, automargin=True, tickmode="auto"),
441
+ )
442
+ return fig
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.4
2
+ ## PiHoleLongTermStats v.0.2.6
3
3
  ## License : MIT
4
4
 
5
5
  import re
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.4
2
+ ## PiHoleLongTermStats v.0.2.6
3
3
  ## License : MIT
4
4
 
5
5
  import logging
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: piholelongtermstats
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: A dashboard to explore long-term DNS query data from Pi-hole FTL database files.
5
5
  Author-email: Davis Thomas Daniel <davisthomasdaniel@gmail.com>
6
6
  Maintainer-email: Davis Thomas Daniel <davisthomasdaniel@gmail.com>
@@ -22,11 +22,10 @@ Provides-Extra: develop
22
22
  Requires-Dist: pytest==9.0.1; extra == "develop"
23
23
  Requires-Dist: ruff; extra == "develop"
24
24
  Requires-Dist: build==1.3.0; extra == "develop"
25
- Requires-Dist: twine==6.2.0; extra == "develop"
26
25
  Requires-Dist: pytest-cov==7.0.0; extra == "develop"
27
26
  Dynamic: license-file
28
27
 
29
- # Pi Hole Long Term Statistics v.0.2.4
28
+ # Pi Hole Long Term Statistics v.0.2.6
30
29
 
31
30
  <div align="center">
32
31
 
@@ -74,11 +73,13 @@ See more [screenshots](https://github.com/davistdaniel/PiHoleLongTermStats/blob/
74
73
  > Using your actual Pi-hole FTL db file for querying is **not** recommended and it is advised to use a copy. Place the copy in the project root or specify its path using the `--db_path` argument or `PIHOLE_LT_STATS_DB_PATH` environment variable. In any case, PiHoleLongTermStats does not monitor for changes in the Pi-hole FTL db file even if you mount it.
75
74
 
76
75
  > [!TIP]
77
- > * You can set up a cron job to periodically copy the FTL database to the `db_path` `PIHOLE_LT_STATS_DB_PATH` location, ensuring your stats are updated without touching the live database. Use the reload button in the dashboard to refresh the stats.
76
+ > * You can set up a cron job to periodically copy the FTL database to the `db_path` `PIHOLE_LT_STATS_DB_PATH` location, ensuring your stats are updated without touching the actual database. Use the reload button in the dashboard to refresh the stats.
78
77
  > * Set your timezone (e.g "Europe/Berlin") using `--timezone` or `PIHOLE_LT_STATS_TIMEZONE`.
79
78
 
80
79
  > [!IMPORTANT]
81
- > * PiHoleLongTermStats reloads the Pi-hole FTL database and recalculates stats whenever the dashboard is refreshed or the reload button is clicked.
80
+ > * PiHoleLongTermStats reloads the Pi-hole FTL database and recalculates stats whenever the the reload button is clicked.
81
+ > * Refreshing the page uing the browser's refresh button, will always serve cached dashboard and data.
82
+ > * To update the dashboard with newer data from the database, you have to **manually** click the reload button (🔄) in the dashboard. Alternatively, you can restart the script or the docker container (using a cron job, for instance).
82
83
  > * If no date range is selected, it uses the default period set by `--days` or `PIHOLE_LT_STATS_DAYS`.
83
84
  > * Large date ranges may lead to increased memory usage.
84
85
  > * When multiple database files are provided, PiHoleLongTermStats concatenates them into a single dataframe and sorts the combined data by timestamp. Duplicate entries are **not** removed for calculating stats. Consolidating multiple databases can lead to increased memory usage.
@@ -104,7 +105,7 @@ If you have a copy of your `pihole-FTL.db` file, you can quickly run the dashboa
104
105
 
105
106
  ```bash
106
107
  # Example: Copy from the default Pi-hole location
107
- sudo cp /etc/pihole/pihole-FTL.db .
108
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
108
109
  # Ensure the user running the app has read permissions
109
110
  sudo chown $USER:$USER pihole-FTL.db
110
111
  ```
@@ -176,7 +177,7 @@ If installing using python, you can install from this git repo or as a package f
176
177
 
177
178
  ```bash
178
179
  # Example: Copy from the default Pi-hole location
179
- sudo cp /etc/pihole/pihole-FTL.db .
180
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
180
181
  # Ensure the user running the app has read permissions
181
182
  sudo chown $USER:$USER pihole-FTL.db
182
183
  ```
@@ -231,7 +232,7 @@ If installing using python, you can install from this git repo or as a package f
231
232
  3. Make a copy/backup of your `pihole-FTL.db` (**Important!**) and place it in the PiHoleLongTermStats directory:
232
233
  ```bash
233
234
  # Example: Copy from the default Pi-hole location
234
- sudo cp /etc/pihole/pihole-FTL.db .
235
+ sudo sqlite3 /etc/pihole/pihole-FTL.db ".backup ./pihole-FTL.db"
235
236
  # Ensure the user running the app has read permissions
236
237
  sudo chown $USER:$USER pihole-FTL.db
237
238
  ```
@@ -285,6 +286,7 @@ You can configure the application using command-line arguments or environment va
285
286
  ## 🧑‍💻 Contributing
286
287
 
287
288
  Any contribution, feature ideas or bug fixes are always welcome.
289
+ In case of new features, opening an issue and discussion before implementation is recommended.
288
290
  This project uses uv for dependency management.
289
291
 
290
292
  ### Development workflow
@@ -7,5 +7,4 @@ psutil==7.2.2
7
7
  pytest==9.0.1
8
8
  ruff
9
9
  build==1.3.0
10
- twine==6.2.0
11
10
  pytest-cov==7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "piholelongtermstats"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  dependencies = [
9
9
  "dash==4.1.0",
10
10
  "pandas==3.0.3",
@@ -32,7 +32,6 @@ develop = [
32
32
  "pytest==9.0.1",
33
33
  "ruff",
34
34
  "build==1.3.0",
35
- "twine==6.2.0",
36
35
  "pytest-cov==7.0.0",
37
36
  ]
38
37
 
@@ -51,3 +50,8 @@ exclude = ["docs*", "tests*","screenshots*","build*","dist*"]
51
50
 
52
51
  [tool.setuptools.package-data]
53
52
  piholelongtermstats = ["assets/*"]
53
+
54
+ [dependency-groups]
55
+ dev = [
56
+ "jupyter>=1.1.1",
57
+ ]
@@ -5,6 +5,8 @@ from piholelongtermstats.plot import (
5
5
  generate_plot_data,
6
6
  generate_queries_over_time,
7
7
  generate_client_activity_over_time,
8
+ generate_top_blocked_domains,
9
+ generate_top_allowed_domains,
8
10
  )
9
11
  from piholelongtermstats.process import prepare_hourly_aggregated_data
10
12
 
@@ -32,6 +34,8 @@ class TestGeneratePlotData:
32
34
  "top_clients_stacked",
33
35
  "blocked_df",
34
36
  "allowed_df",
37
+ "blocked_df_by_client",
38
+ "allowed_df_by_client",
35
39
  "reply_time_df",
36
40
  "client_list",
37
41
  "data_span_days",
@@ -81,6 +85,42 @@ class TestGeneratePlotData:
81
85
  assert len(blocked_df) <= n_domains
82
86
  assert len(allowed_df) <= n_domains
83
87
 
88
+ blocked_df_by_client = result["blocked_df_by_client"]
89
+ allowed_df_by_client = result["allowed_df_by_client"]
90
+
91
+ assert isinstance(blocked_df_by_client, pd.DataFrame)
92
+ assert isinstance(allowed_df_by_client, pd.DataFrame)
93
+ assert "client" in blocked_df_by_client.columns
94
+ assert "client" in allowed_df_by_client.columns
95
+
96
+ def test_generate_top_blocked_domains_function(self, plot_dataframe):
97
+ n_clients = 5
98
+ n_domains = 10
99
+ result = generate_plot_data(plot_dataframe, n_clients, n_domains)
100
+ callback_data = result
101
+
102
+ fig = generate_top_blocked_domains(callback_data, n_domains=n_domains)
103
+ assert isinstance(fig, go.Figure)
104
+
105
+ client = plot_dataframe["client"].iloc[0]
106
+ fig_client = generate_top_blocked_domains(callback_data, n_domains=n_domains, client=client)
107
+ assert isinstance(fig_client, go.Figure)
108
+ assert client in fig_client.layout.title.text
109
+
110
+ def test_generate_top_allowed_domains_function(self, plot_dataframe):
111
+ n_clients = 5
112
+ n_domains = 10
113
+ result = generate_plot_data(plot_dataframe, n_clients, n_domains)
114
+ callback_data = result
115
+
116
+ fig = generate_top_allowed_domains(callback_data, n_domains=n_domains)
117
+ assert isinstance(fig, go.Figure)
118
+
119
+ client = plot_dataframe["client"].iloc[0]
120
+ fig_client = generate_top_allowed_domains(callback_data, n_domains=n_domains, client=client)
121
+ assert isinstance(fig_client, go.Figure)
122
+ assert client in fig_client.layout.title.text
123
+
84
124
  def test_generate_plot_data_reply_time_df(self, plot_dataframe):
85
125
  """Test reply time dataframe."""
86
126
  n_clients = 5
@@ -1,3 +0,0 @@
1
- ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.4
3
- ## License : MIT