piholelongtermstats 0.2.1__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.1 → piholelongtermstats-0.2.2}/PKG-INFO +2 -2
  2. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/README.md +1 -1
  3. piholelongtermstats-0.2.2/piholelongtermstats/__init__.py +3 -0
  4. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/app.py +23 -130
  5. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/db.py +1 -1
  6. piholelongtermstats-0.2.2/piholelongtermstats/plot.py +327 -0
  7. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/process.py +1 -1
  8. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/stats.py +1 -1
  9. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/PKG-INFO +2 -2
  10. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/pyproject.toml +1 -1
  11. piholelongtermstats-0.2.1/piholelongtermstats/__init__.py +0 -3
  12. piholelongtermstats-0.2.1/piholelongtermstats/plot.py +0 -144
  13. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/LICENSE +0 -0
  14. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/favicon.ico +0 -0
  15. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/logo_phlts.png +0 -0
  16. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/style.css +0 -0
  17. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/SOURCES.txt +0 -0
  18. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/dependency_links.txt +0 -0
  19. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/entry_points.txt +0 -0
  20. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/requires.txt +0 -0
  21. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/top_level.txt +0 -0
  22. {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: piholelongtermstats
3
- Version: 0.2.1
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>
@@ -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.1
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.1
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,7 +19,11 @@ 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
28
  __version__ = "0.2.1"
26
29
 
@@ -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(
@@ -696,12 +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
713
  html.H5(
702
714
  "Client acitivity for all clients. The data is aggregated hourly."
703
715
  ),
704
- dcc.Graph(id="client-activity-view"),
716
+ dcc.Graph(id="client-activity-view", figure=initial_activity_fig),
705
717
  ],
706
718
  className="cardplot",
707
719
  ),
@@ -1036,7 +1048,7 @@ gc.collect()
1036
1048
  Input("reload-button", "n_clicks"),
1037
1049
  State("date-picker-range", "start_date"),
1038
1050
  State("date-picker-range", "end_date"),
1039
- prevent_initial_call=False,
1051
+ prevent_initial_call=True,
1040
1052
  )
1041
1053
  def reload_page(n_clicks, start_date, end_date):
1042
1054
  global PHLTS_CALLBACK_DATA
@@ -1084,76 +1096,13 @@ def reload_page(n_clicks, start_date, end_date):
1084
1096
  Output("filtered-view", "figure"),
1085
1097
  Input("client-filter", "value"),
1086
1098
  Input("reload-button", "n_clicks"),
1099
+ prevent_initial_call=True,
1087
1100
  )
1088
1101
  def update_filtered_view(client, n_clicks):
1089
1102
  logging.info("Updating Queries over time plot...")
1090
1103
  global PHLTS_CALLBACK_DATA
1091
1104
 
1092
- dff_grouped = PHLTS_CALLBACK_DATA["hourly_agg"]
1093
-
1094
- if client:
1095
- logging.info(f"Selected client : {client}")
1096
- dff_grouped = dff_grouped[dff_grouped["client"] == client]
1097
- title_text = f"DNS Queries Over Time for {client}"
1098
- else:
1099
- dff_grouped = (
1100
- dff_grouped.groupby(["timestamp", "status_type"])["count"]
1101
- .sum()
1102
- .reset_index()
1103
- )
1104
- title_text = "DNS Queries Over Time for All Clients"
1105
-
1106
- # Fill missing data with 0
1107
- all_times = pd.date_range(
1108
- dff_grouped["timestamp"].min(), dff_grouped["timestamp"].max(), freq="h"
1109
- )
1110
- status_types = ["Other", "Allowed", "Blocked"]
1111
- full_index = pd.MultiIndex.from_product(
1112
- [all_times, status_types], names=["timestamp", "status_type"]
1113
- )
1114
- dff_grouped = (
1115
- dff_grouped.set_index(["timestamp", "status_type"])
1116
- .reindex(full_index, fill_value=0)
1117
- .reset_index()
1118
- )
1119
- dff_grouped["status_type"] = pd.Categorical(
1120
- dff_grouped["status_type"], categories=status_types, ordered=True
1121
- )
1122
- dff_grouped = dff_grouped.sort_values("status_type")
1123
-
1124
- fig = px.area(
1125
- dff_grouped,
1126
- x="timestamp",
1127
- y="count",
1128
- color="status_type",
1129
- line_group="status_type",
1130
- title=title_text,
1131
- color_discrete_map={
1132
- "Allowed": "#10b981",
1133
- "Blocked": "#ef4444",
1134
- "Other": "#b99529",
1135
- },
1136
- template="plotly_white",
1137
- labels={
1138
- "timestamp": "Date",
1139
- "count": "Count",
1140
- "status_type": "Query Status",
1141
- },
1142
- )
1143
-
1144
- fig.update_traces(
1145
- mode="lines",
1146
- line_shape="spline",
1147
- line=dict(width=0.5),
1148
- stackgroup="one",
1149
- )
1150
-
1151
- fig.update_layout(
1152
- legend=dict(orientation="h", yanchor="top", y=-0.4, xanchor="center", x=0.5)
1153
- )
1154
-
1155
- del dff_grouped
1156
- gc.collect()
1105
+ fig = generate_queries_over_time(callback_data=PHLTS_CALLBACK_DATA, client=client)
1157
1106
 
1158
1107
  return fig
1159
1108
 
@@ -1162,72 +1111,16 @@ def update_filtered_view(client, n_clicks):
1162
1111
  Output("client-activity-view", "figure"),
1163
1112
  Input("client-filter", "value"),
1164
1113
  Input("reload-button", "n_clicks"),
1114
+ prevent_initial_call=True,
1165
1115
  )
1166
1116
  def update_client_activity(client, n_clicks):
1167
1117
  logging.info("Updating Client activity over time plot...")
1168
1118
  global PHLTS_CALLBACK_DATA
1169
1119
 
1170
- dff_grouped = PHLTS_CALLBACK_DATA["hourly_agg"]
1171
- top_clients = PHLTS_CALLBACK_DATA["top_clients"]
1172
-
1173
- if client:
1174
- logging.info(f"Selected client : {client}")
1175
- dff_grouped = dff_grouped[dff_grouped["client"] == client]
1176
- dff_grouped = (
1177
- dff_grouped.groupby(["timestamp", "client"])["count"].sum().reset_index()
1178
- )
1179
- title_text = f"Activity for {client}"
1180
- clients_to_show = [client]
1181
- else:
1182
- dff_grouped = dff_grouped[dff_grouped["client"].isin(top_clients)]
1183
- dff_grouped = (
1184
- dff_grouped.groupby(["timestamp", "client"])["count"].sum().reset_index()
1185
- )
1186
- title_text = f"Activity for top {args.n_clients} clients"
1187
- clients_to_show = top_clients
1188
-
1189
- all_times = pd.date_range(
1190
- dff_grouped["timestamp"].min(), dff_grouped["timestamp"].max(), freq="h"
1191
- )
1192
- full_index = pd.MultiIndex.from_product(
1193
- [all_times, clients_to_show], names=["timestamp", "client"]
1194
- )
1195
- pivot_df = (
1196
- dff_grouped.set_index(["timestamp", "client"])
1197
- .reindex(full_index, fill_value=0)
1198
- .reset_index()
1199
- )
1200
-
1201
- default_colors = px.colors.qualitative.Plotly
1202
- client_color_map = dict(zip(top_clients, itertools.cycle(default_colors)))
1203
-
1204
- fig = px.area(
1205
- pivot_df,
1206
- x="timestamp",
1207
- y="count",
1208
- color="client",
1209
- line_group="client",
1210
- title=title_text,
1211
- color_discrete_map=client_color_map,
1212
- template="plotly_white",
1213
- labels={"timestamp": "Date", "count": "Count", "client": "Client IP"},
1214
- )
1215
-
1216
- fig.update_traces(
1217
- mode="lines",
1218
- line_shape="spline",
1219
- line=dict(width=0.2),
1220
- stackgroup="one",
1221
- connectgaps=False,
1120
+ fig = generate_client_activity_over_time(
1121
+ callback_data=PHLTS_CALLBACK_DATA, n_clients=args.n_clients, client=client
1222
1122
  )
1223
1123
 
1224
- fig.update_layout(
1225
- legend=dict(orientation="h", yanchor="top", y=-0.4, xanchor="center", x=0.5)
1226
- )
1227
-
1228
- del dff_grouped, pivot_df
1229
- gc.collect()
1230
-
1231
1124
  return fig
1232
1125
 
1233
1126
 
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.1
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.1
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.1
2
+ ## PiHoleLongTermStats v.0.2.2
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.1
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>
@@ -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.1
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.1"
3
+ version = "0.2.2"
4
4
  dependencies = [
5
5
  "dash==3.0.4",
6
6
  "pandas==2.2.3",
@@ -1,3 +0,0 @@
1
- ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.1
3
- ## License : MIT
@@ -1,144 +0,0 @@
1
- ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.1
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
- }