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.
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/PKG-INFO +2 -2
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/README.md +1 -1
- piholelongtermstats-0.2.2/piholelongtermstats/__init__.py +3 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/app.py +23 -130
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/db.py +1 -1
- piholelongtermstats-0.2.2/piholelongtermstats/plot.py +327 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/process.py +1 -1
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/stats.py +1 -1
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/PKG-INFO +2 -2
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/pyproject.toml +1 -1
- piholelongtermstats-0.2.1/piholelongtermstats/__init__.py +0 -3
- piholelongtermstats-0.2.1/piholelongtermstats/plot.py +0 -144
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/LICENSE +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/favicon.ico +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/logo_phlts.png +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/style.css +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/SOURCES.txt +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/dependency_links.txt +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/entry_points.txt +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/requires.txt +0 -0
- {piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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
|
+
# 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
|
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
## Author : Davis T. Daniel
|
|
2
|
-
## PiHoleLongTermStats v.0.2.
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
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
|
|
|
@@ -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
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: piholelongtermstats
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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,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
|
-
}
|
|
File without changes
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/favicon.ico
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/logo_phlts.png
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/style.css
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/requires.txt
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.1 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|