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.
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/PKG-INFO +3 -3
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/README.md +1 -1
- piholelongtermstats-0.2.2/piholelongtermstats/__init__.py +3 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/app.py +38 -142
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/db.py +1 -1
- piholelongtermstats-0.2.2/piholelongtermstats/plot.py +327 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/process.py +1 -1
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/stats.py +1 -1
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/PKG-INFO +3 -3
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/pyproject.toml +2 -2
- piholelongtermstats-0.2.0/piholelongtermstats/__init__.py +0 -3
- piholelongtermstats-0.2.0/piholelongtermstats/plot.py +0 -144
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/LICENSE +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/favicon.ico +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/logo_phlts.png +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/style.css +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/SOURCES.txt +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/dependency_links.txt +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/entry_points.txt +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/requires.txt +0 -0
- {piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/top_level.txt +0 -0
- {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.
|
|
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://
|
|
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.
|
|
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,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
|
|
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.
|
|
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("
|
|
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(
|
|
702
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
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()
|
|
@@ -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.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/PKG-INFO
RENAMED
|
@@ -1,12 +1,12 @@
|
|
|
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>
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
Project-URL: Homepage, https://github.com/davistdaniel/PiHoleLongTermStats
|
|
9
|
-
Project-URL: Documentation, https://
|
|
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.
|
|
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.
|
|
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://
|
|
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,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
|
-
}
|
|
File without changes
|
{piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/favicon.ico
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/logo_phlts.png
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats/assets/style.css
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/requires.txt
RENAMED
|
File without changes
|
{piholelongtermstats-0.2.0 → piholelongtermstats-0.2.2}/piholelongtermstats.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|