piholelongtermstats 0.2.0__tar.gz → 0.2.2__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 (22) hide show
  1. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/PKG-INFO +3 -3
  2. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/README.md +1 -1
  3. piholelongtermstats-0.2.2/piholelongtermstats/__init__.py +3 -0
  4. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/app.py +38 -142
  5. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/db.py +1 -1
  6. piholelongtermstats-0.2.2/piholelongtermstats/plot.py +327 -0
  7. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/process.py +1 -1
  8. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/stats.py +1 -1
  9. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/PKG-INFO +3 -3
  10. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/pyproject.toml +2 -2
  11. piholelongtermstats-0.2.0/piholelongtermstats/__init__.py +0 -3
  12. piholelongtermstats-0.2.0/piholelongtermstats/plot.py +0 -144
  13. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/LICENSE +0 -0
  14. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/favicon.ico +0 -0
  15. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/logo_phlts.png +0 -0
  16. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/style.css +0 -0
  17. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/SOURCES.txt +0 -0
  18. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/dependency_links.txt +0 -0
  19. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/entry_points.txt +0 -0
  20. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/requires.txt +0 -0
  21. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/top_level.txt +0 -0
  22. {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/setup.cfg +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: piholelongtermstats
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
7
7
  License-Expression: MIT
8
8
  Project-URL: Homepage, https://github.com/davistdaniel/PiHoleLongTermStats
9
- Project-URL: Documentation, https://readthedocs.org
9
+ Project-URL: Documentation, https://github.com/davistdaniel/PiHoleLongTermStats
10
10
  Project-URL: Repository, https://github.com/davistdaniel/PiHoleLongTermStats.git
11
11
  Project-URL: Bug Tracker, https://github.com/davistdaniel/PiHoleLongTermStats/issues
12
12
  Keywords: dashboard,pihole,visualization,plotly,dash
@@ -26,7 +26,7 @@ Requires-Dist: twine==6.2.0; extra == "develop"
26
26
  Requires-Dist: pytest-cov==7.0.0; extra == "develop"
27
27
  Dynamic: license-file
28
28
 
29
- # Pi Hole Long Term Statistics v.0.2.0
29
+ # Pi Hole Long Term Statistics v.0.2.2
30
30
 
31
31
  A dashboard built with **Dash** and **Plotly** to explore long-term DNS query data from **Pi-hole v.6** FTL database files. Visualize allowed vs blocked domains, top clients, and query trends over time. I originally made this for my own use, but if you find it useful, a ⭐ would be awesome!
32
32
 
@@ -1,4 +1,4 @@
1
- # Pi Hole Long Term Statistics v.0.2.0
1
+ # Pi Hole Long Term Statistics v.0.2.2
2
2
 
3
3
  A dashboard built with **Dash** and **Plotly** to explore long-term DNS query data from **Pi-hole v.6** FTL database files. Visualize allowed vs blocked domains, top clients, and query trends over time. I originally made this for my own use, but if you find it useful, a ⭐ would be awesome!
4
4
 
@@ -0,0 +1,3 @@
1
+ ## Author : Davis T. Daniel
2
+ ## PiHoleLongTermStats v.0.2.2
3
+ ## License : MIT
@@ -1,11 +1,10 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.0
2
+ ## PiHoleLongTermStats v.0.2.2
3
3
  ## License : MIT
4
4
 
5
5
  import os
6
6
  import gc
7
7
  import argparse
8
- import itertools
9
8
  import logging
10
9
  import psutil
11
10
  import plotly.express as px
@@ -20,9 +19,13 @@ from piholelongtermstats.process import (
20
19
  prepare_hourly_aggregated_data,
21
20
  )
22
21
  from piholelongtermstats.stats import compute_stats
23
- from piholelongtermstats.plot import generate_plot_data
22
+ from piholelongtermstats.plot import (
23
+ generate_plot_data,
24
+ generate_client_activity_over_time,
25
+ generate_queries_over_time,
26
+ )
24
27
 
25
- __version__ = "0.2.0"
28
+ __version__ = "0.2.1"
26
29
 
27
30
  # logging setup
28
31
  logging.basicConfig(
@@ -193,6 +196,15 @@ def serve_layout(
193
196
  del df, hourly_data
194
197
  gc.collect()
195
198
 
199
+ # generate initial plots
200
+
201
+ initial_filtered_fig = generate_queries_over_time(
202
+ callback_data=callback_data, client=None
203
+ )
204
+ initial_activity_fig = generate_client_activity_over_time(
205
+ callback_data=callback_data, n_clients=args.n_clients, client=None
206
+ )
207
+
196
208
  layout = html.Div(
197
209
  [
198
210
  html.Div(
@@ -688,7 +700,7 @@ def serve_layout(
688
700
  html.Div(
689
701
  [
690
702
  html.H2("Queries over time"),
691
- html.H5("Aggregated hourly"),
703
+ html.H5("Queries from all clients. The data is aggregated hourly."),
692
704
  dcc.Dropdown(
693
705
  options=[
694
706
  {"label": c, "value": c} for c in plot_data["client_list"]
@@ -696,10 +708,12 @@ def serve_layout(
696
708
  id="client-filter",
697
709
  placeholder="Select a Client",
698
710
  ),
699
- dcc.Graph(id="filtered-view"),
711
+ dcc.Graph(id="filtered-view", figure=initial_filtered_fig),
700
712
  html.H2("Client Activity Over Time"),
701
- html.H5("Aggregated hourly"),
702
- dcc.Graph(id="client-activity-view"),
713
+ html.H5(
714
+ "Client acitivity for all clients. The data is aggregated hourly."
715
+ ),
716
+ dcc.Graph(id="client-activity-view", figure=initial_activity_fig),
703
717
  ],
704
718
  className="cardplot",
705
719
  ),
@@ -707,6 +721,7 @@ def serve_layout(
707
721
  html.Div(
708
722
  [
709
723
  html.H2("Top Blocked Domains"),
724
+ html.H5(f"Top {args.n_domains} blocked domains."),
710
725
  dcc.Graph(
711
726
  id="top-blocked-domains",
712
727
  figure=px.bar(
@@ -735,6 +750,7 @@ def serve_layout(
735
750
  html.Div(
736
751
  [
737
752
  html.H2("Top Allowed Domains"),
753
+ html.H5(f"Top {args.n_domains} allowed domains."),
738
754
  dcc.Graph(
739
755
  id="top-allowed-domains",
740
756
  figure=px.bar(
@@ -764,6 +780,7 @@ def serve_layout(
764
780
  html.Div(
765
781
  [
766
782
  html.H2("Top Client Activity"),
783
+ html.H5(f"Top {args.n_clients} clients based on total queries."),
767
784
  dcc.Graph(
768
785
  id="top-clients",
769
786
  figure=px.bar(
@@ -777,7 +794,6 @@ def serve_layout(
777
794
  },
778
795
  color="status_type",
779
796
  barmode="stack",
780
- title="Top Clients by Query Type",
781
797
  color_discrete_map={
782
798
  "Allowed": "#10b981",
783
799
  "Blocked": "#ef4444",
@@ -926,6 +942,7 @@ def serve_layout(
926
942
  html.Div(
927
943
  [
928
944
  html.H2("Average Reply Time"),
945
+ html.H5("Daily average reply time in ms."),
929
946
  dcc.Graph(
930
947
  id="avg-reply-time",
931
948
  figure=px.line(
@@ -1025,19 +1042,18 @@ app.layout = html.Div(
1025
1042
  del initial_layout
1026
1043
  gc.collect()
1027
1044
 
1045
+
1028
1046
  @app.callback(
1029
1047
  Output("page-container", "children"),
1030
1048
  Input("reload-button", "n_clicks"),
1031
1049
  State("date-picker-range", "start_date"),
1032
1050
  State("date-picker-range", "end_date"),
1033
- prevent_initial_call=False,
1051
+ prevent_initial_call=True,
1034
1052
  )
1035
1053
  def reload_page(n_clicks, start_date, end_date):
1036
1054
  global PHLTS_CALLBACK_DATA
1037
1055
 
1038
- logging.info(
1039
- f"Reload button clicked. Selected date range: {start_date, end_date}"
1040
- )
1056
+ logging.info(f"Reload button clicked. Selected date range: {start_date, end_date}")
1041
1057
 
1042
1058
  chunksize_list, latest_ts_list, oldest_ts_list = (
1043
1059
  [],
@@ -1075,163 +1091,43 @@ def reload_page(n_clicks, start_date, end_date):
1075
1091
 
1076
1092
  return layout.children
1077
1093
 
1094
+
1078
1095
  @app.callback(
1079
1096
  Output("filtered-view", "figure"),
1080
1097
  Input("client-filter", "value"),
1081
1098
  Input("reload-button", "n_clicks"),
1099
+ prevent_initial_call=True,
1082
1100
  )
1083
1101
  def update_filtered_view(client, n_clicks):
1084
1102
  logging.info("Updating Queries over time plot...")
1085
1103
  global PHLTS_CALLBACK_DATA
1086
1104
 
1087
- dff_grouped = PHLTS_CALLBACK_DATA["hourly_agg"]
1088
-
1089
- if client:
1090
- logging.info(f"Selected client : {client}")
1091
- dff_grouped = dff_grouped[dff_grouped["client"] == client]
1092
- title_text = f"DNS Queries Over Time for {client}"
1093
- else:
1094
- dff_grouped = (
1095
- dff_grouped.groupby(["timestamp", "status_type"])["count"]
1096
- .sum()
1097
- .reset_index()
1098
- )
1099
- title_text = "DNS Queries Over Time for All Clients"
1100
-
1101
- # Fill missing data with 0
1102
- all_times = pd.date_range(
1103
- dff_grouped["timestamp"].min(), dff_grouped["timestamp"].max(), freq="h"
1104
- )
1105
- status_types = ["Other", "Allowed", "Blocked"]
1106
- full_index = pd.MultiIndex.from_product(
1107
- [all_times, status_types], names=["timestamp", "status_type"]
1108
- )
1109
- dff_grouped = (
1110
- dff_grouped.set_index(["timestamp", "status_type"])
1111
- .reindex(full_index, fill_value=0)
1112
- .reset_index()
1113
- )
1114
- dff_grouped["status_type"] = pd.Categorical(
1115
- dff_grouped["status_type"], categories=status_types, ordered=True
1116
- )
1117
- dff_grouped = dff_grouped.sort_values("status_type")
1118
-
1119
- fig = px.area(
1120
- dff_grouped,
1121
- x="timestamp",
1122
- y="count",
1123
- color="status_type",
1124
- line_group="status_type",
1125
- title=title_text,
1126
- color_discrete_map={
1127
- "Allowed": "#10b981",
1128
- "Blocked": "#ef4444",
1129
- "Other": "#b99529",
1130
- },
1131
- template="plotly_white",
1132
- labels={
1133
- "timestamp": "Date",
1134
- "count": "Count",
1135
- "status_type": "Query Status",
1136
- },
1137
- )
1138
-
1139
- fig.update_traces(
1140
- mode="lines",
1141
- line_shape="spline",
1142
- line=dict(width=0.5),
1143
- stackgroup="one",
1144
- )
1145
-
1146
- fig.update_layout(
1147
- legend=dict(orientation="h", yanchor="top", y=-0.4, xanchor="center", x=0.5)
1148
- )
1149
-
1150
- del dff_grouped
1151
- gc.collect()
1105
+ fig = generate_queries_over_time(callback_data=PHLTS_CALLBACK_DATA, client=client)
1152
1106
 
1153
1107
  return fig
1154
1108
 
1109
+
1155
1110
  @app.callback(
1156
1111
  Output("client-activity-view", "figure"),
1157
1112
  Input("client-filter", "value"),
1158
1113
  Input("reload-button", "n_clicks"),
1114
+ prevent_initial_call=True,
1159
1115
  )
1160
1116
  def update_client_activity(client, n_clicks):
1161
1117
  logging.info("Updating Client activity over time plot...")
1162
1118
  global PHLTS_CALLBACK_DATA
1163
1119
 
1164
- dff_grouped = PHLTS_CALLBACK_DATA["hourly_agg"]
1165
- top_clients = PHLTS_CALLBACK_DATA["top_clients"]
1166
-
1167
- if client:
1168
- logging.info(f"Selected client : {client}")
1169
- dff_grouped = dff_grouped[dff_grouped["client"] == client]
1170
- dff_grouped = (
1171
- dff_grouped.groupby(["timestamp", "client"])["count"]
1172
- .sum()
1173
- .reset_index()
1174
- )
1175
- title_text = f"Activity for {client}"
1176
- clients_to_show = [client]
1177
- else:
1178
- dff_grouped = dff_grouped[dff_grouped["client"].isin(top_clients)]
1179
- dff_grouped = (
1180
- dff_grouped.groupby(["timestamp", "client"])["count"]
1181
- .sum()
1182
- .reset_index()
1183
- )
1184
- title_text = f"Activity for top {args.n_clients} clients"
1185
- clients_to_show = top_clients
1186
-
1187
- all_times = pd.date_range(
1188
- dff_grouped["timestamp"].min(), dff_grouped["timestamp"].max(), freq="h"
1120
+ fig = generate_client_activity_over_time(
1121
+ callback_data=PHLTS_CALLBACK_DATA, n_clients=args.n_clients, client=client
1189
1122
  )
1190
- full_index = pd.MultiIndex.from_product(
1191
- [all_times, clients_to_show], names=["timestamp", "client"]
1192
- )
1193
- pivot_df = (
1194
- dff_grouped.set_index(["timestamp", "client"])
1195
- .reindex(full_index, fill_value=0)
1196
- .reset_index()
1197
- )
1198
-
1199
- default_colors = px.colors.qualitative.Plotly
1200
- client_color_map = dict(zip(top_clients, itertools.cycle(default_colors)))
1201
-
1202
- fig = px.area(
1203
- pivot_df,
1204
- x="timestamp",
1205
- y="count",
1206
- color="client",
1207
- line_group="client",
1208
- title=title_text,
1209
- color_discrete_map=client_color_map,
1210
- template="plotly_white",
1211
- labels={"timestamp": "Date", "count": "Count", "client": "Client IP"},
1212
- )
1213
-
1214
- fig.update_traces(
1215
- mode="lines",
1216
- line_shape="spline",
1217
- line=dict(width=0.2),
1218
- stackgroup="one",
1219
- connectgaps=False,
1220
- )
1221
-
1222
- fig.update_layout(
1223
- legend=dict(orientation="h", yanchor="top", y=-0.4, xanchor="center", x=0.5)
1224
- )
1225
-
1226
- del dff_grouped, pivot_df
1227
- gc.collect()
1228
1123
 
1229
1124
  return fig
1230
1125
 
1231
1126
 
1232
-
1233
1127
  def run():
1234
1128
  app.run(host="0.0.0.0", port=args.port, debug=False)
1129
+
1130
+
1235
1131
  # serve
1236
1132
  if __name__ == "__main__":
1237
1133
  run()
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.0
2
+ ## PiHoleLongTermStats v.0.2.2
3
3
  ## License : MIT
4
4
 
5
5
  import sqlite3
@@ -0,0 +1,327 @@
1
+ ## Author : Davis T. Daniel
2
+ ## PiHoleLongTermStats v.0.2.2
3
+ ## License : MIT
4
+
5
+ import logging
6
+ import pandas as pd
7
+ import gc
8
+ import plotly.express as px
9
+ import itertools
10
+
11
+
12
+ def generate_plot_data(df, n_clients, n_domains):
13
+ """Generate plot data"""
14
+
15
+ logging.info("Generating plot data...")
16
+
17
+ def shorten(s):
18
+ return s if len(s) <= 45 else f"{s[:20]}...{s[-20:]}"
19
+
20
+ top_clients = df["client"].value_counts().nlargest(n_clients).index
21
+ top_clients_stacked = (
22
+ df[df["client"].isin(top_clients)]
23
+ .groupby(["client", "status_type"])
24
+ .size()
25
+ .reset_index(name="count")
26
+ )
27
+ top_clients_stacked["client"] = pd.Categorical(
28
+ top_clients_stacked["client"],
29
+ categories=top_clients_stacked.groupby("client")["count"]
30
+ .sum()
31
+ .sort_values(ascending=False)
32
+ .index,
33
+ ordered=True,
34
+ )
35
+ top_clients_stacked = top_clients_stacked.sort_values(
36
+ ["client", "count"], ascending=[True, False]
37
+ )
38
+ logging.info("Generated plot data for top clients.")
39
+
40
+ # plot data for allowed and blocked domains
41
+ tmp_blocked = df[df["status_type"] == "Blocked"].copy()
42
+ tmp_blocked["domain"] = tmp_blocked["domain"].apply(shorten)
43
+
44
+ blocked_df = (
45
+ tmp_blocked["domain"]
46
+ .value_counts()
47
+ .nlargest(n_domains)
48
+ .reset_index()
49
+ .rename(columns={"index": "Count", "domain": "Domain"})
50
+ )
51
+
52
+ tmp_allowed = df[df["status_type"] == "Allowed"].copy()
53
+ tmp_allowed["domain"] = tmp_allowed["domain"].apply(shorten)
54
+
55
+ allowed_df = (
56
+ tmp_allowed["domain"]
57
+ .value_counts()
58
+ .nlargest(n_domains)
59
+ .reset_index()
60
+ .rename(columns={"index": "Count", "domain": "Domain"})
61
+ )
62
+
63
+ logging.info("Generated plot data for allowed and blocked domains.")
64
+
65
+ # plot data for reply time over days
66
+ reply_time_df = (
67
+ df.groupby("date")["reply_time"]
68
+ .mean()
69
+ .mul(1000)
70
+ .reset_index(name="reply_time_ms")
71
+ )
72
+
73
+ logging.info("Generated plot data for reply time plot")
74
+ client_list = df["client"].unique().tolist()
75
+
76
+ # plot data for doman-client scatter. take minimum from n_domains or n_clients
77
+ top_clients = df["client"].value_counts().nlargest(min(n_domains, n_clients)).index
78
+ top_domains = df["domain"].value_counts().nlargest(min(n_domains, n_clients)).index
79
+
80
+ df_top = df.loc[
81
+ df["client"].isin(top_clients) & df["domain"].isin(top_domains)
82
+ ].copy()
83
+ df_top.loc[:, "domain"] = df_top["domain"].apply(shorten)
84
+
85
+ client_domain_scatter_df = (
86
+ df_top.groupby(["client", "domain", "status_type"])
87
+ .size()
88
+ .reset_index(name="count")
89
+ .sort_values(by="count")
90
+ )
91
+
92
+ # heatmap
93
+ order = [
94
+ "Monday",
95
+ "Tuesday",
96
+ "Wednesday",
97
+ "Thursday",
98
+ "Friday",
99
+ "Saturday",
100
+ "Sunday",
101
+ ]
102
+
103
+ day_hour_heatmap = (
104
+ df.groupby(["day_name", "hour"])
105
+ .size()
106
+ .reset_index(name="count")
107
+ .pivot(index="day_name", columns="hour", values="count")
108
+ .fillna(0)
109
+ .reindex(order)
110
+ )
111
+
112
+ blocked_day_hour_heatmap = (
113
+ tmp_blocked.groupby(["day_name", "hour"])
114
+ .size()
115
+ .reset_index(name="count")
116
+ .pivot(index="day_name", columns="hour", values="count")
117
+ .fillna(0)
118
+ .reindex(order)
119
+ )
120
+
121
+ allowed_day_hour_heatmap = (
122
+ tmp_allowed.groupby(["day_name", "hour"])
123
+ .size()
124
+ .reset_index(name="count")
125
+ .pivot(index="day_name", columns="hour", values="count")
126
+ .fillna(0)
127
+ .reindex(order)
128
+ )
129
+
130
+ del df_top, top_clients, top_domains, tmp_allowed, tmp_blocked
131
+ gc.collect()
132
+
133
+ logging.info("Plot data generation complete")
134
+
135
+ return {
136
+ "top_clients_stacked": top_clients_stacked,
137
+ "blocked_df": blocked_df,
138
+ "allowed_df": allowed_df,
139
+ "reply_time_df": reply_time_df,
140
+ "client_list": client_list,
141
+ "data_span_days": (df["timestamp"].max() - df["timestamp"].min()).days,
142
+ "client_domain_scatter_df": client_domain_scatter_df,
143
+ "day_hour_heatmap": day_hour_heatmap,
144
+ "blocked_day_hour_heatmap": blocked_day_hour_heatmap,
145
+ "allowed_day_hour_heatmap": allowed_day_hour_heatmap,
146
+ }
147
+
148
+
149
+ def generate_queries_over_time(callback_data, client=None):
150
+ dff_grouped = callback_data["hourly_agg"]
151
+
152
+ if client is not None:
153
+ logging.info(f"Selected client : {client}")
154
+ dff_grouped = dff_grouped[dff_grouped["client"] == client]
155
+ title_text = f"DNS Queries Over Time for {client}"
156
+ else:
157
+ dff_grouped = (
158
+ dff_grouped.groupby(["timestamp", "status_type"])["count"]
159
+ .sum()
160
+ .reset_index()
161
+ )
162
+ title_text = "DNS Queries Over Time for All Clients"
163
+
164
+ if dff_grouped.empty:
165
+ fig = px.area(
166
+ title=f"No activity available for {client}"
167
+ if client
168
+ else "No activity available",
169
+ template="plotly_white",
170
+ )
171
+ fig.update_layout(
172
+ xaxis_title="Date",
173
+ yaxis_title="Count",
174
+ annotations=[
175
+ dict(
176
+ text="No data to display",
177
+ x=0.5,
178
+ y=0.5,
179
+ xref="paper",
180
+ yref="paper",
181
+ showarrow=False,
182
+ )
183
+ ],
184
+ )
185
+ return fig
186
+
187
+ # Fill missing data with 0
188
+ all_times = pd.date_range(
189
+ dff_grouped["timestamp"].min(), dff_grouped["timestamp"].max(), freq="h"
190
+ )
191
+ status_types = ["Other", "Allowed", "Blocked"]
192
+ full_index = pd.MultiIndex.from_product(
193
+ [all_times, status_types], names=["timestamp", "status_type"]
194
+ )
195
+ dff_grouped = (
196
+ dff_grouped.set_index(["timestamp", "status_type"])
197
+ .reindex(full_index, fill_value=0)
198
+ .reset_index()
199
+ )
200
+ dff_grouped["status_type"] = pd.Categorical(
201
+ dff_grouped["status_type"], categories=status_types, ordered=True
202
+ )
203
+ dff_grouped = dff_grouped.sort_values("status_type")
204
+
205
+ fig = px.area(
206
+ dff_grouped,
207
+ x="timestamp",
208
+ y="count",
209
+ color="status_type",
210
+ line_group="status_type",
211
+ title=title_text,
212
+ color_discrete_map={
213
+ "Allowed": "#10b981",
214
+ "Blocked": "#ef4444",
215
+ "Other": "#b99529",
216
+ },
217
+ template="plotly_white",
218
+ labels={
219
+ "timestamp": "Date",
220
+ "count": "Count",
221
+ "status_type": "Query Status",
222
+ },
223
+ )
224
+
225
+ fig.update_traces(
226
+ mode="lines",
227
+ line_shape="spline",
228
+ line=dict(width=0.5),
229
+ stackgroup="one",
230
+ )
231
+
232
+ fig.update_layout(
233
+ legend=dict(orientation="h", yanchor="top", y=-0.4, xanchor="center", x=0.5)
234
+ )
235
+
236
+ del dff_grouped
237
+ gc.collect()
238
+
239
+ return fig
240
+
241
+
242
+ def generate_client_activity_over_time(callback_data, n_clients, client=None):
243
+ dff_grouped = callback_data["hourly_agg"]
244
+ top_clients = callback_data["top_clients"]
245
+
246
+ if client is not None:
247
+ logging.info(f"Selected client : {client}")
248
+ dff_grouped = dff_grouped[dff_grouped["client"] == client]
249
+ dff_grouped = (
250
+ dff_grouped.groupby(["timestamp", "client"])["count"].sum().reset_index()
251
+ )
252
+ title_text = f"Activity for {client}"
253
+ clients_to_show = [client]
254
+ else:
255
+ dff_grouped = dff_grouped[dff_grouped["client"].isin(top_clients)]
256
+ dff_grouped = (
257
+ dff_grouped.groupby(["timestamp", "client"])["count"].sum().reset_index()
258
+ )
259
+ title_text = f"Activity for top {n_clients} clients"
260
+ clients_to_show = top_clients
261
+
262
+ if dff_grouped.empty:
263
+ fig = px.area(
264
+ title=f"No activity available for {client}"
265
+ if client
266
+ else "No activity available",
267
+ template="plotly_white",
268
+ )
269
+ fig.update_layout(
270
+ xaxis_title="Date",
271
+ yaxis_title="Count",
272
+ annotations=[
273
+ dict(
274
+ text="No data to display",
275
+ x=0.5,
276
+ y=0.5,
277
+ xref="paper",
278
+ yref="paper",
279
+ showarrow=False,
280
+ )
281
+ ],
282
+ )
283
+ return fig
284
+
285
+ all_times = pd.date_range(
286
+ dff_grouped["timestamp"].min(), dff_grouped["timestamp"].max(), freq="h"
287
+ )
288
+ full_index = pd.MultiIndex.from_product(
289
+ [all_times, clients_to_show], names=["timestamp", "client"]
290
+ )
291
+ pivot_df = (
292
+ dff_grouped.set_index(["timestamp", "client"])
293
+ .reindex(full_index, fill_value=0)
294
+ .reset_index()
295
+ )
296
+
297
+ default_colors = px.colors.qualitative.Plotly
298
+ client_color_map = dict(zip(top_clients, itertools.cycle(default_colors)))
299
+
300
+ fig = px.area(
301
+ pivot_df,
302
+ x="timestamp",
303
+ y="count",
304
+ color="client",
305
+ line_group="client",
306
+ title=title_text,
307
+ color_discrete_map=client_color_map,
308
+ template="plotly_white",
309
+ labels={"timestamp": "Date", "count": "Count", "client": "Client IP"},
310
+ )
311
+
312
+ fig.update_traces(
313
+ mode="lines",
314
+ line_shape="spline",
315
+ line=dict(width=0.2),
316
+ stackgroup="one",
317
+ connectgaps=False,
318
+ )
319
+
320
+ fig.update_layout(
321
+ legend=dict(orientation="h", yanchor="top", y=-0.4, xanchor="center", x=0.5)
322
+ )
323
+
324
+ del dff_grouped, pivot_df
325
+ gc.collect()
326
+
327
+ return fig
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.0
2
+ ## PiHoleLongTermStats v.0.2.2
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.0
2
+ ## PiHoleLongTermStats v.0.2.2
3
3
  ## License : MIT
4
4
 
5
5
  import logging
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: piholelongtermstats
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
7
7
  License-Expression: MIT
8
8
  Project-URL: Homepage, https://github.com/davistdaniel/PiHoleLongTermStats
9
- Project-URL: Documentation, https://readthedocs.org
9
+ Project-URL: Documentation, https://github.com/davistdaniel/PiHoleLongTermStats
10
10
  Project-URL: Repository, https://github.com/davistdaniel/PiHoleLongTermStats.git
11
11
  Project-URL: Bug Tracker, https://github.com/davistdaniel/PiHoleLongTermStats/issues
12
12
  Keywords: dashboard,pihole,visualization,plotly,dash
@@ -26,7 +26,7 @@ Requires-Dist: twine==6.2.0; extra == "develop"
26
26
  Requires-Dist: pytest-cov==7.0.0; extra == "develop"
27
27
  Dynamic: license-file
28
28
 
29
- # Pi Hole Long Term Statistics v.0.2.0
29
+ # Pi Hole Long Term Statistics v.0.2.2
30
30
 
31
31
  A dashboard built with **Dash** and **Plotly** to explore long-term DNS query data from **Pi-hole v.6** FTL database files. Visualize allowed vs blocked domains, top clients, and query trends over time. I originally made this for my own use, but if you find it useful, a ⭐ would be awesome!
32
32
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "piholelongtermstats"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  dependencies = [
5
5
  "dash==3.0.4",
6
6
  "pandas==2.2.3",
@@ -34,7 +34,7 @@ develop = [
34
34
 
35
35
  [project.urls]
36
36
  Homepage = "https://github.com/davistdaniel/PiHoleLongTermStats"
37
- Documentation = "https://readthedocs.org"
37
+ Documentation = "https://github.com/davistdaniel/PiHoleLongTermStats"
38
38
  Repository = "https://github.com/davistdaniel/PiHoleLongTermStats.git"
39
39
  "Bug Tracker" = "https://github.com/davistdaniel/PiHoleLongTermStats/issues"
40
40
 
@@ -1,3 +0,0 @@
1
- ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.0
3
- ## License : MIT
@@ -1,144 +0,0 @@
1
- ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.0
3
- ## License : MIT
4
-
5
- import logging
6
- import pandas as pd
7
- import gc
8
-
9
-
10
- def generate_plot_data(df, n_clients, n_domains):
11
- """Generate plot data"""
12
-
13
- logging.info("Generating plot data...")
14
-
15
- def shorten(s):
16
- return s if len(s) <= 45 else f"{s[:20]}...{s[-20:]}"
17
-
18
- top_clients = df["client"].value_counts().nlargest(n_clients).index
19
- top_clients_stacked = (
20
- df[df["client"].isin(top_clients)]
21
- .groupby(["client", "status_type"])
22
- .size()
23
- .reset_index(name="count")
24
- )
25
- top_clients_stacked["client"] = pd.Categorical(
26
- top_clients_stacked["client"],
27
- categories=top_clients_stacked.groupby("client")["count"]
28
- .sum()
29
- .sort_values(ascending=False)
30
- .index,
31
- ordered=True,
32
- )
33
- top_clients_stacked = top_clients_stacked.sort_values(
34
- ["client", "count"], ascending=[True, False]
35
- )
36
- logging.info("Generated plot data for top clients.")
37
-
38
- # plot data for allowed and blocked domains
39
- tmp_blocked = df[df["status_type"] == "Blocked"].copy()
40
- tmp_blocked["domain"] = tmp_blocked["domain"].apply(shorten)
41
-
42
- blocked_df = (
43
- tmp_blocked["domain"]
44
- .value_counts()
45
- .nlargest(n_domains)
46
- .reset_index()
47
- .rename(columns={"index": "Count", "domain": "Domain"})
48
- )
49
-
50
- tmp_allowed = df[df["status_type"] == "Allowed"].copy()
51
- tmp_allowed["domain"] = tmp_allowed["domain"].apply(shorten)
52
-
53
- allowed_df = (
54
- tmp_allowed["domain"]
55
- .value_counts()
56
- .nlargest(n_domains)
57
- .reset_index()
58
- .rename(columns={"index": "Count", "domain": "Domain"})
59
- )
60
-
61
- logging.info("Generated plot data for allowed and blocked domains.")
62
-
63
- # plot data for reply time over days
64
- reply_time_df = (
65
- df.groupby("date")["reply_time"]
66
- .mean()
67
- .mul(1000)
68
- .reset_index(name="reply_time_ms")
69
- )
70
-
71
- logging.info("Generated plot data for reply time plot")
72
- client_list = df["client"].unique().tolist()
73
-
74
- # plot data for doman-client scatter. take minimum from n_domains or n_clients
75
- top_clients = df["client"].value_counts().nlargest(min(n_domains, n_clients)).index
76
- top_domains = df["domain"].value_counts().nlargest(min(n_domains, n_clients)).index
77
-
78
- df_top = df.loc[
79
- df["client"].isin(top_clients) & df["domain"].isin(top_domains)
80
- ].copy()
81
- df_top.loc[:, "domain"] = df_top["domain"].apply(shorten)
82
-
83
- client_domain_scatter_df = (
84
- df_top.groupby(["client", "domain", "status_type"])
85
- .size()
86
- .reset_index(name="count")
87
- .sort_values(by="count")
88
- )
89
-
90
- # heatmap
91
- order = [
92
- "Monday",
93
- "Tuesday",
94
- "Wednesday",
95
- "Thursday",
96
- "Friday",
97
- "Saturday",
98
- "Sunday",
99
- ]
100
-
101
- day_hour_heatmap = (
102
- df.groupby(["day_name", "hour"])
103
- .size()
104
- .reset_index(name="count")
105
- .pivot(index="day_name", columns="hour", values="count")
106
- .fillna(0)
107
- .reindex(order)
108
- )
109
-
110
- blocked_day_hour_heatmap = (
111
- tmp_blocked.groupby(["day_name", "hour"])
112
- .size()
113
- .reset_index(name="count")
114
- .pivot(index="day_name", columns="hour", values="count")
115
- .fillna(0)
116
- .reindex(order)
117
- )
118
-
119
- allowed_day_hour_heatmap = (
120
- tmp_allowed.groupby(["day_name", "hour"])
121
- .size()
122
- .reset_index(name="count")
123
- .pivot(index="day_name", columns="hour", values="count")
124
- .fillna(0)
125
- .reindex(order)
126
- )
127
-
128
- del df_top, top_clients, top_domains, tmp_allowed, tmp_blocked
129
- gc.collect()
130
-
131
- logging.info("Plot data generation complete")
132
-
133
- return {
134
- "top_clients_stacked": top_clients_stacked,
135
- "blocked_df": blocked_df,
136
- "allowed_df": allowed_df,
137
- "reply_time_df": reply_time_df,
138
- "client_list": client_list,
139
- "data_span_days": (df["timestamp"].max() - df["timestamp"].min()).days,
140
- "client_domain_scatter_df": client_domain_scatter_df,
141
- "day_hour_heatmap": day_hour_heatmap,
142
- "blocked_day_hour_heatmap": blocked_day_hour_heatmap,
143
- "allowed_day_hour_heatmap": allowed_day_hour_heatmap,
144
- }