piholelongtermstats 0.2.2__tar.gz → 0.2.4__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 (26) hide show
  1. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/PKG-INFO +38 -6
  2. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/README.md +34 -2
  3. piholelongtermstats-0.2.4/piholelongtermstats/__init__.py +3 -0
  4. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/app.py +27 -15
  5. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/assets/style.css +36 -30
  6. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/db.py +27 -18
  7. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/plot.py +55 -18
  8. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/process.py +39 -13
  9. piholelongtermstats-0.2.4/piholelongtermstats/stats.py +528 -0
  10. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats.egg-info/PKG-INFO +38 -6
  11. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats.egg-info/SOURCES.txt +5 -1
  12. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats.egg-info/requires.txt +3 -3
  13. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/pyproject.toml +9 -5
  14. piholelongtermstats-0.2.4/tests/test_db.py +362 -0
  15. piholelongtermstats-0.2.4/tests/test_plot.py +274 -0
  16. piholelongtermstats-0.2.4/tests/test_process.py +301 -0
  17. piholelongtermstats-0.2.4/tests/test_stats.py +467 -0
  18. piholelongtermstats-0.2.2/piholelongtermstats/__init__.py +0 -3
  19. piholelongtermstats-0.2.2/piholelongtermstats/stats.py +0 -439
  20. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/LICENSE +0 -0
  21. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/assets/favicon.ico +0 -0
  22. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats/assets/logo_phlts.png +0 -0
  23. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats.egg-info/dependency_links.txt +0 -0
  24. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats.egg-info/entry_points.txt +0 -0
  25. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/piholelongtermstats.egg-info/top_level.txt +0 -0
  26. {piholelongtermstats-0.2.2 → piholelongtermstats-0.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: piholelongtermstats
3
- Version: 0.2.2
3
+ Version: 0.2.4
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>
@@ -14,10 +14,10 @@ Classifier: Programming Language :: Python
14
14
  Requires-Python: >=3.11
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: dash==3.0.4
18
- Requires-Dist: pandas==2.2.3
17
+ Requires-Dist: dash==4.1.0
18
+ Requires-Dist: pandas==3.0.3
19
19
  Requires-Dist: plotly==6.0.1
20
- Requires-Dist: psutil==7.1.2
20
+ Requires-Dist: psutil==7.2.2
21
21
  Provides-Extra: develop
22
22
  Requires-Dist: pytest==9.0.1; extra == "develop"
23
23
  Requires-Dist: ruff; extra == "develop"
@@ -26,11 +26,21 @@ 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.2
29
+ # Pi Hole Long Term Statistics v.0.2.4
30
30
 
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!
31
+ <div align="center">
32
+
33
+ ![PyPI - Version](https://img.shields.io/pypi/v/piholelongtermstats)
34
+ ![GitHub last commit](https://img.shields.io/github/last-commit/davistdaniel/piholelongtermstats)
35
+ ![GitHub License](https://img.shields.io/github/license/davistdaniel/piholelongtermstats)
36
+ ![phlts_tests](https://img.shields.io/github/actions/workflow/status/davistdaniel/piholelongtermstats/run-tests.yml?label=tests)
32
37
 
33
38
 
39
+
40
+ </div>
41
+
42
+ 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!
43
+
34
44
  **Disclaimer : This is an unofficial, third-party project. The Pi Hole team and the development of [PiHole](https://pi-hole.net/) software is not related to this project.**
35
45
 
36
46
 
@@ -275,6 +285,28 @@ You can configure the application using command-line arguments or environment va
275
285
  ## 🧑‍💻 Contributing
276
286
 
277
287
  Any contribution, feature ideas or bug fixes are always welcome.
288
+ This project uses uv for dependency management.
289
+
290
+ ### Development workflow
291
+ * Clone the repository.
292
+
293
+ ```bash
294
+ git clone https://github.com/davistdaniel/PiHoleLongTermStats.git
295
+ cd PiHoleLongTermStats
296
+ ```
297
+
298
+ * From the project directory (with uv already installed), install dependencies:
299
+
300
+ ```bash
301
+ uv sync --extra develop
302
+ ```
303
+ * Run the test suite:
304
+
305
+ ```bash
306
+ uv run python -m pytest tests/ -v -s --cov=piholelongtermstats --cov-report term-missing
307
+ ```
308
+ * Make your changes.
309
+ * Submit a pull request.
278
310
 
279
311
  ## Star History
280
312
 
@@ -1,8 +1,18 @@
1
- # Pi Hole Long Term Statistics v.0.2.2
1
+ # Pi Hole Long Term Statistics v.0.2.4
2
2
 
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!
3
+ <div align="center">
4
+
5
+ ![PyPI - Version](https://img.shields.io/pypi/v/piholelongtermstats)
6
+ ![GitHub last commit](https://img.shields.io/github/last-commit/davistdaniel/piholelongtermstats)
7
+ ![GitHub License](https://img.shields.io/github/license/davistdaniel/piholelongtermstats)
8
+ ![phlts_tests](https://img.shields.io/github/actions/workflow/status/davistdaniel/piholelongtermstats/run-tests.yml?label=tests)
4
9
 
5
10
 
11
+
12
+ </div>
13
+
14
+ 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!
15
+
6
16
  **Disclaimer : This is an unofficial, third-party project. The Pi Hole team and the development of [PiHole](https://pi-hole.net/) software is not related to this project.**
7
17
 
8
18
 
@@ -247,6 +257,28 @@ You can configure the application using command-line arguments or environment va
247
257
  ## 🧑‍💻 Contributing
248
258
 
249
259
  Any contribution, feature ideas or bug fixes are always welcome.
260
+ This project uses uv for dependency management.
261
+
262
+ ### Development workflow
263
+ * Clone the repository.
264
+
265
+ ```bash
266
+ git clone https://github.com/davistdaniel/PiHoleLongTermStats.git
267
+ cd PiHoleLongTermStats
268
+ ```
269
+
270
+ * From the project directory (with uv already installed), install dependencies:
271
+
272
+ ```bash
273
+ uv sync --extra develop
274
+ ```
275
+ * Run the test suite:
276
+
277
+ ```bash
278
+ uv run python -m pytest tests/ -v -s --cov=piholelongtermstats --cov-report term-missing
279
+ ```
280
+ * Make your changes.
281
+ * Submit a pull request.
250
282
 
251
283
  ## Star History
252
284
 
@@ -0,0 +1,3 @@
1
+ ## Author : Davis T. Daniel
2
+ ## PiHoleLongTermStats v.0.2.4
3
+ ## License : MIT
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.2
2
+ ## PiHoleLongTermStats v.0.2.4
3
3
  ## License : MIT
4
4
 
5
5
  import os
@@ -25,7 +25,7 @@ from piholelongtermstats.plot import (
25
25
  generate_queries_over_time,
26
26
  )
27
27
 
28
- __version__ = "0.2.1"
28
+ __version__ = "0.2.4"
29
29
 
30
30
  # logging setup
31
31
  logging.basicConfig(
@@ -629,9 +629,15 @@ def serve_layout(
629
629
  html.Div(
630
630
  [
631
631
  html.H3("Longest Idle Period"),
632
- html.P(f"{stats['max_idle_ms']:,.0f} s"),
633
632
  html.P(
634
- f"Between {stats['before_gap']} and {stats['after_gap']}",
633
+ f"{stats['max_idle_ms']:,.0f} s"
634
+ if stats['max_idle_ms'] is not None
635
+ else "N/A"
636
+ ),
637
+ html.P(
638
+ f"Between {stats['before_gap']} and {stats['after_gap']}"
639
+ if stats['before_gap'] and stats['after_gap']
640
+ else "N/A",
635
641
  style={"fontSize": "14px", "color": "#777"},
636
642
  ),
637
643
  ],
@@ -652,7 +658,9 @@ def serve_layout(
652
658
  },
653
659
  ),
654
660
  html.P(
655
- f"Avg reply time: {stats['slowest_avg_reply_time'] * 1000:.2f} ms",
661
+ f"Avg reply time: {stats['slowest_avg_reply_time'] * 1000:.2f} ms"
662
+ if stats['slowest_avg_reply_time']
663
+ else "N/A",
656
664
  style={"fontSize": "14px", "color": "#777"},
657
665
  ),
658
666
  ],
@@ -993,11 +1001,13 @@ chunksize_list, latest_ts_list, oldest_ts_list = (
993
1001
 
994
1002
  for db in db_paths:
995
1003
  conn = connect_to_sql(db)
996
- chunksize, latest_ts, oldest_ts = probe_sample_df(conn)
997
- chunksize_list.append(chunksize)
998
- latest_ts_list.append(latest_ts.tz_convert(ZoneInfo(args.timezone)))
999
- oldest_ts_list.append(oldest_ts.tz_convert(ZoneInfo(args.timezone)))
1000
- conn.close()
1004
+ try:
1005
+ chunksize, latest_ts, oldest_ts = probe_sample_df(conn)
1006
+ chunksize_list.append(chunksize)
1007
+ latest_ts_list.append(latest_ts.tz_convert(ZoneInfo(args.timezone)))
1008
+ oldest_ts_list.append(oldest_ts.tz_convert(ZoneInfo(args.timezone)))
1009
+ finally:
1010
+ conn.close()
1001
1011
 
1002
1012
  logging.info(
1003
1013
  f"Latest date-time from all databases : {max(latest_ts_list)} (TZ: {args.timezone})"
@@ -1063,11 +1073,13 @@ def reload_page(n_clicks, start_date, end_date):
1063
1073
 
1064
1074
  for db in db_paths:
1065
1075
  conn = connect_to_sql(db)
1066
- chunksize, latest_ts, oldest_ts = probe_sample_df(conn)
1067
- chunksize_list.append(chunksize)
1068
- latest_ts_list.append(latest_ts.tz_convert(ZoneInfo(args.timezone)))
1069
- oldest_ts_list.append(oldest_ts.tz_convert(ZoneInfo(args.timezone)))
1070
- conn.close()
1076
+ try:
1077
+ chunksize, latest_ts, oldest_ts = probe_sample_df(conn)
1078
+ chunksize_list.append(chunksize)
1079
+ latest_ts_list.append(latest_ts.tz_convert(ZoneInfo(args.timezone)))
1080
+ oldest_ts_list.append(oldest_ts.tz_convert(ZoneInfo(args.timezone)))
1081
+ finally:
1082
+ conn.close()
1071
1083
 
1072
1084
  logging.info(
1073
1085
  f"Latest date-time from all databases : {max(latest_ts_list)} (TZ: {args.timezone})"
@@ -302,40 +302,46 @@ h3 {
302
302
  transform: scale(1.03);
303
303
  }
304
304
 
305
- .date-picker-btn .DateRangePickerInput {
306
- background-color: transparent;
307
- border: none;
308
- display: flex;
309
- align-items: center;
310
- gap: 6px;
311
- height: 36px;
305
+ .dash-datepicker {
306
+ background: transparent !important;
307
+ border: none !important;
308
+ box-shadow: none !important;
312
309
  }
313
310
 
314
- .date-picker-btn .DateInput_input {
315
- -webkit-appearance: none;
316
- -moz-appearance: none;
317
- appearance: none;
311
+ .date-picker-btn.dash-datepicker-input-wrapper {
312
+ background: transparent !important;
313
+ border: none !important;
314
+ box-shadow: none !important;
318
315
  display: flex;
319
316
  align-items: center;
320
- justify-content: center;
321
- background: #24292e;
322
- color: #f7f9fb;
323
- border: none;
324
- border-radius: 50px;
325
- padding: 0 20px;
326
- height: 46px;
327
- font-size: 16px;
328
- font-weight: 200;
329
- text-align: center;
330
- cursor: pointer;
331
- line-height: normal;
317
+ gap: 10px;
318
+ padding: 0;
332
319
  }
333
320
 
334
- .date-picker-btn .DateRangePickerArrow {
335
- color: #f7f9fb;
336
- font-size: 16px;
337
- display: flex;
338
- align-items: center;
339
- justify-content: center;
340
- height: 100%;
321
+ .date-picker-btn .dash-datepicker-start-date,
322
+ .date-picker-btn .dash-datepicker-end-date {
323
+ background-color: #24292e !important;
324
+ color: white !important;
325
+ border: none !important;
326
+ outline: none !important;
327
+ padding: 8px 20px !important;
328
+ border-radius: 50px !important;
329
+ font-size: 16px !important;
330
+ font-weight: 100 !important;
331
+ cursor: pointer !important;
332
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1) !important;
333
+ transition: all 0.2s ease-in-out !important;
334
+ text-align: center !important;
335
+ width: auto !important;
336
+ }
337
+
338
+ .date-picker-btn .dash-datepicker-start-date::placeholder,
339
+ .date-picker-btn .dash-datepicker-end-date::placeholder {
340
+ color: rgba(255, 255, 255, 0.6) !important;
341
+ }
342
+
343
+ .date-picker-btn .dash-datepicker-range-arrow,
344
+ .date-picker-btn .dash-datepicker-caret-icon {
345
+ fill: #f7f9fb !important;
346
+ flex-shrink: 0;
341
347
  }
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.2
2
+ ## PiHoleLongTermStats v.0.2.4
3
3
  ## License : MIT
4
4
 
5
5
  import sqlite3
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  import psutil
9
9
  import pandas as pd
10
10
  import logging
11
- from zoneinfo import ZoneInfo
11
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
12
12
  import gc
13
13
 
14
14
 
@@ -31,14 +31,20 @@ def connect_to_sql(db_path):
31
31
 
32
32
 
33
33
  def probe_sample_df(conn):
34
- """compute basic stats from a subset of the databases"""
35
-
36
- # calculate safe chunksize to not overload system memory
34
+ """Calculate safe chunksize based on available memory and retrieve timestamp range.
35
+
36
+ Analyzes a sample of the database to estimate memory requirements per row
37
+ and retrieves the oldest and latest timestamps in the database.
38
+ """
37
39
  sample_query = """SELECT id, timestamp, type, status, domain, client, reply_time
38
- FROM queries LIMIT 5"""
40
+ FROM queries LIMIT 100"""
39
41
  sample_df = pd.read_sql_query(sample_query, conn)
42
+ if sample_df.empty:
43
+ raise ValueError("No data from database for the selected time frame.")
44
+
40
45
  sample_df["timestamp"] = pd.to_datetime(sample_df["timestamp"], unit="s")
41
46
 
47
+
42
48
  available_memory = psutil.virtual_memory().available
43
49
  memory_per_row = sample_df.memory_usage(deep=True).sum() / len(sample_df)
44
50
  safe_memory = available_memory * 0.5
@@ -63,7 +69,7 @@ def probe_sample_df(conn):
63
69
  def get_timestamp_range(days, start_date, end_date, timezone):
64
70
  try:
65
71
  tz = ZoneInfo(timezone)
66
- except Exception:
72
+ except ZoneInfoNotFoundError:
67
73
  logging.warning(f"Invalid timezone '{timezone}', using UTC")
68
74
  tz = ZoneInfo("UTC")
69
75
 
@@ -120,11 +126,13 @@ def read_pihole_ftl_db(
120
126
  f"Reading data from PiHole-FTL database(s) for timestamps ranging from {start_timestamp} to {end_timestamp} (TZ: UTC)..."
121
127
  )
122
128
 
123
- query = f"""
129
+ # no sql injection
130
+ query = """
124
131
  SELECT id, timestamp, type, status, domain, client, reply_time
125
132
  FROM queries
126
- WHERE timestamp >= {start_timestamp} AND timestamp < {end_timestamp};
133
+ WHERE timestamp >= ? AND timestamp < ?;
127
134
  """
135
+ params = [start_timestamp, end_timestamp]
128
136
 
129
137
  for db_idx, db_path in enumerate(db_paths):
130
138
  logging.info(
@@ -132,12 +140,13 @@ def read_pihole_ftl_db(
132
140
  )
133
141
  conn = connect_to_sql(db_path)
134
142
 
135
- chunk_num = 0
136
- for chunk in pd.read_sql_query(query, conn, chunksize=chunksize[db_idx]):
137
- chunk_num += 1
138
- logging.info(
139
- f"Processing dataframe chunk {chunk_num} from database {db_idx + 1} at {db_path}..."
140
- )
141
- yield chunk
142
-
143
- conn.close()
143
+ try:
144
+ chunk_num = 0
145
+ for chunk in pd.read_sql_query(query, conn, params=params, chunksize=chunksize[db_idx]):
146
+ chunk_num += 1
147
+ logging.info(
148
+ f"Processing dataframe chunk {chunk_num} from database {db_idx + 1} at {db_path}..."
149
+ )
150
+ yield chunk
151
+ finally:
152
+ conn.close()
@@ -1,5 +1,5 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.2
2
+ ## PiHoleLongTermStats v.0.2.4
3
3
  ## License : MIT
4
4
 
5
5
  import logging
@@ -10,7 +10,16 @@ import itertools
10
10
 
11
11
 
12
12
  def generate_plot_data(df, n_clients, n_domains):
13
- """Generate plot data"""
13
+ """Generate plot data for dashboard visualizations.
14
+
15
+ Args:
16
+ df: Pre-processed DataFrame with query data.
17
+ n_clients: Number of top clients to include in plots.
18
+ n_domains: Number of top domains to include in plots.
19
+
20
+ Returns:
21
+ dict: Dictionary containing DataFrames and data for various plots.
22
+ """
14
23
 
15
24
  logging.info("Generating plot data...")
16
25
 
@@ -24,17 +33,19 @@ def generate_plot_data(df, n_clients, n_domains):
24
33
  .size()
25
34
  .reset_index(name="count")
26
35
  )
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
- )
36
+
37
+ if not top_clients_stacked.empty:
38
+ top_clients_stacked["client"] = pd.Categorical(
39
+ top_clients_stacked["client"],
40
+ categories=top_clients_stacked.groupby("client")["count"]
41
+ .sum()
42
+ .sort_values(ascending=False)
43
+ .index,
44
+ ordered=True,
45
+ )
46
+ top_clients_stacked = top_clients_stacked.sort_values(
47
+ ["client", "count"], ascending=[True, False]
48
+ )
38
49
  logging.info("Generated plot data for top clients.")
39
50
 
40
51
  # plot data for allowed and blocked domains
@@ -46,7 +57,7 @@ def generate_plot_data(df, n_clients, n_domains):
46
57
  .value_counts()
47
58
  .nlargest(n_domains)
48
59
  .reset_index()
49
- .rename(columns={"index": "Count", "domain": "Domain"})
60
+ .rename(columns={"domain": "Domain", "count": "count"})
50
61
  )
51
62
 
52
63
  tmp_allowed = df[df["status_type"] == "Allowed"].copy()
@@ -57,7 +68,7 @@ def generate_plot_data(df, n_clients, n_domains):
57
68
  .value_counts()
58
69
  .nlargest(n_domains)
59
70
  .reset_index()
60
- .rename(columns={"index": "Count", "domain": "Domain"})
71
+ .rename(columns={"domain": "Domain", "count": "count"})
61
72
  )
62
73
 
63
74
  logging.info("Generated plot data for allowed and blocked domains.")
@@ -131,6 +142,11 @@ def generate_plot_data(df, n_clients, n_domains):
131
142
  gc.collect()
132
143
 
133
144
  logging.info("Plot data generation complete")
145
+
146
+ if not df.empty:
147
+ data_span_days = (df["timestamp"].max() - df["timestamp"].min()).days
148
+ else:
149
+ data_span_days = 0
134
150
 
135
151
  return {
136
152
  "top_clients_stacked": top_clients_stacked,
@@ -138,7 +154,7 @@ def generate_plot_data(df, n_clients, n_domains):
138
154
  "allowed_df": allowed_df,
139
155
  "reply_time_df": reply_time_df,
140
156
  "client_list": client_list,
141
- "data_span_days": (df["timestamp"].max() - df["timestamp"].min()).days,
157
+ "data_span_days": data_span_days,
142
158
  "client_domain_scatter_df": client_domain_scatter_df,
143
159
  "day_hour_heatmap": day_hour_heatmap,
144
160
  "blocked_day_hour_heatmap": blocked_day_hour_heatmap,
@@ -147,6 +163,15 @@ def generate_plot_data(df, n_clients, n_domains):
147
163
 
148
164
 
149
165
  def generate_queries_over_time(callback_data, client=None):
166
+ """Generate an area chart showing DNS queries over time.
167
+
168
+ Args:
169
+ callback_data: Dictionary containing hourly aggregation data.
170
+ client: Optional client filter to show queries for a specific client.
171
+
172
+ Returns:
173
+ plotly.graph_objects.Figure: Area chart of queries over time.
174
+ """
150
175
  dff_grouped = callback_data["hourly_agg"]
151
176
 
152
177
  if client is not None:
@@ -193,7 +218,8 @@ def generate_queries_over_time(callback_data, client=None):
193
218
  [all_times, status_types], names=["timestamp", "status_type"]
194
219
  )
195
220
  dff_grouped = (
196
- dff_grouped.set_index(["timestamp", "status_type"])
221
+ dff_grouped[["timestamp", "status_type", "count"]]
222
+ .set_index(["timestamp", "status_type"])
197
223
  .reindex(full_index, fill_value=0)
198
224
  .reset_index()
199
225
  )
@@ -240,6 +266,16 @@ def generate_queries_over_time(callback_data, client=None):
240
266
 
241
267
 
242
268
  def generate_client_activity_over_time(callback_data, n_clients, client=None):
269
+ """Generate an area chart showing client activity over time.
270
+
271
+ Args:
272
+ callback_data: Dictionary containing hourly aggregation data and top clients list.
273
+ n_clients: Number of top clients to display when no specific client is selected.
274
+ client: Optional client filter to show activity for a specific client.
275
+
276
+ Returns:
277
+ plotly.graph_objects.Figure: Area chart of client activity over time.
278
+ """
243
279
  dff_grouped = callback_data["hourly_agg"]
244
280
  top_clients = callback_data["top_clients"]
245
281
 
@@ -295,7 +331,8 @@ def generate_client_activity_over_time(callback_data, n_clients, client=None):
295
331
  )
296
332
 
297
333
  default_colors = px.colors.qualitative.Plotly
298
- client_color_map = dict(zip(top_clients, itertools.cycle(default_colors)))
334
+ # build color map for all clients that will be shown (not just top_clients)
335
+ client_color_map = dict(zip(clients_to_show, itertools.cycle(default_colors)))
299
336
 
300
337
  fig = px.area(
301
338
  pivot_df,
@@ -1,14 +1,22 @@
1
1
  ## Author : Davis T. Daniel
2
- ## PiHoleLongTermStats v.0.2.2
2
+ ## PiHoleLongTermStats v.0.2.4
3
3
  ## License : MIT
4
4
 
5
5
  import re
6
6
  import logging
7
- from zoneinfo import ZoneInfo
7
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
8
8
  import pandas as pd
9
9
 
10
10
 
11
11
  def _is_valid_regex(pattern):
12
+ """Check if a string is a valid regular expression pattern.
13
+
14
+ Args:
15
+ pattern: String to validate as regex pattern.
16
+
17
+ Returns:
18
+ bool: True if pattern is valid regex, False otherwise.
19
+ """
12
20
  try:
13
21
  re.compile(pattern)
14
22
  return True
@@ -17,6 +25,15 @@ def _is_valid_regex(pattern):
17
25
 
18
26
 
19
27
  def regex_ignore_domains(df, pattern):
28
+ """Filter out rows where domain matches the given regex pattern.
29
+
30
+ Args:
31
+ df: DataFrame containing a 'domain' column.
32
+ pattern: Regex pattern to match domains for exclusion.
33
+
34
+ Returns:
35
+ DataFrame with matching domains removed, or original DataFrame if pattern is invalid.
36
+ """
20
37
  if _is_valid_regex(pattern):
21
38
  mask = df["domain"].str.contains(pattern, regex=True, na=False)
22
39
  return df[~mask].reset_index(drop=True)
@@ -33,9 +50,9 @@ def preprocess_df(df, timezone="UTC"):
33
50
  logging.info("Pre-processing dataframe...")
34
51
 
35
52
  try:
36
- tz = ZoneInfo(timezone) # noqa: F841
37
- except Exception as e:
38
- logging.warning(f"Invalid timezone '{timezone}', falling back to UTC: {e}")
53
+ ZoneInfo(timezone)
54
+ except ZoneInfoNotFoundError:
55
+ logging.warning(f"Invalid timezone '{timezone}', falling back to UTC")
39
56
  timezone = "UTC"
40
57
 
41
58
  logging.info(f"Selected timezone : {timezone}")
@@ -51,13 +68,13 @@ def preprocess_df(df, timezone="UTC"):
51
68
 
52
69
  # status ids for pihole ftl db, see pi-hole FTL docs
53
70
  logging.info("Processing allowed and blocked status codes...")
54
- allowed_statuses = [2, 3, 12, 13, 14, 17]
55
- blocked_statuses = [1, 4, 5, 6, 7, 8, 9, 10, 11, 15, 16, 18]
56
- df["status_type"] = df["status"].apply(
57
- lambda x: "Allowed"
58
- if x in allowed_statuses
59
- else ("Blocked" if x in blocked_statuses else "Other")
60
- )
71
+ allowed_statuses = {2, 3, 12, 13, 14, 17}
72
+ blocked_statuses = {1, 4, 5, 6, 7, 8, 9, 10, 11, 15, 16, 18}
73
+
74
+ # Use vectorized operations for better performance
75
+ df["status_type"] = "Other"
76
+ df.loc[df["status"].isin(allowed_statuses), "status_type"] = "Allowed"
77
+ df.loc[df["status"].isin(blocked_statuses), "status_type"] = "Blocked"
61
78
 
62
79
  df["day_name"] = df["timestamp"].dt.day_name()
63
80
  df["reply_time"] = pd.to_numeric(df["reply_time"], errors="coerce")
@@ -67,7 +84,16 @@ def preprocess_df(df, timezone="UTC"):
67
84
 
68
85
 
69
86
  def prepare_hourly_aggregated_data(df, n_clients):
70
- """Pre-aggregate data by hour"""
87
+ """Pre-aggregate data by hour for callback processing.
88
+
89
+ Args:
90
+ df: Pre-processed DataFrame with timestamp, status_type, and client columns.
91
+ n_clients: Number of top clients to identify for client activity views.
92
+
93
+ Returns:
94
+ dict: Contains 'hourly_agg' DataFrame with hourly aggregations and
95
+ 'top_clients' list of top n_clients client identifiers.
96
+ """
71
97
  logging.info("Pre-aggregating data by hour for callbacks...")
72
98
 
73
99
  # aggregate by hour, status_type, and client