django-live-logs 0.1.0__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.
@@ -0,0 +1 @@
1
+ recursive-include django_live_logs/templates *
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-live-logs
3
+ Version: 0.1.0
4
+ Summary: A standalone Django package to stream logs over WebSockets.
5
+ Author: Your Name
6
+ Requires-Dist: Django>=3.2
7
+ Requires-Dist: channels>=4.0
8
+ Dynamic: author
9
+ Dynamic: requires-dist
10
+ Dynamic: summary
@@ -0,0 +1,101 @@
1
+ # Django Live Logs
2
+
3
+ **A lightweight, zero-configuration package that streams your Django server logs directly to a beautiful, real-time web dashboard using WebSockets and Redis.**
4
+
5
+ ![Django Live Logs Dashboard](https://i.imgur.com/placeholder.png) *(Imagine a beautiful dark-mode dashboard here)*
6
+
7
+ ## Features
8
+ - ๐Ÿš€ **Real-Time Streaming**: Uses Django Channels and Redis to push logs instantly.
9
+ - ๐ŸŽจ **Built-In Dashboard**: Comes with a sleek, dark-mode Tailwind CSS interface out of the box.
10
+ - ๐Ÿ›ก๏ธ **Secure by Default**: Dashboard and WebSockets are natively protected by Django's `@staff_member_required` and Session cookies.
11
+ - ๐Ÿงต **Non-Blocking**: Dispatches logs in a background thread to prevent ASGI async event loop collisions.
12
+ - ๐Ÿ” **Filtering**: Instantly filter between `INFO`, `WARNING`, and `ERROR` logs.
13
+
14
+ ---
15
+
16
+ ## 1. Installation
17
+
18
+ Install the package via pip:
19
+ ```bash
20
+ pip install django-live-logs
21
+ ```
22
+
23
+ *Note: This package requires `channels` and `channels-redis` to be installed and configured in your project.*
24
+
25
+ ## 2. Configuration
26
+
27
+ ### Step A: Update `settings.py`
28
+ Add the package to your installed apps:
29
+ ```python
30
+ INSTALLED_APPS = [
31
+ # ...
32
+ 'django_live_logs',
33
+ ]
34
+ ```
35
+
36
+ Configure your `LOGGING` dictionary to catch everything (the root logger) and send it to the WebSocket handler:
37
+ ```python
38
+ LOGGING = {
39
+ 'version': 1,
40
+ 'disable_existing_loggers': False,
41
+ 'handlers': {
42
+ 'console': {'class': 'logging.StreamHandler'},
43
+ 'websocket': {'class': 'django_live_logs.handlers.WebSocketLogHandler'},
44
+ },
45
+ 'root': {
46
+ 'handlers': ['console', 'websocket'],
47
+ 'level': 'INFO',
48
+ },
49
+ 'loggers': {
50
+ 'django': {
51
+ 'handlers': ['console', 'websocket'],
52
+ 'level': 'INFO',
53
+ 'propagate': False,
54
+ }
55
+ },
56
+ }
57
+ ```
58
+
59
+ ### Step B: Route the Dashboard (HTTP)
60
+ In your main `urls.py`, include the dashboard URL:
61
+ ```python
62
+ from django.urls import path, include
63
+
64
+ urlpatterns = [
65
+ # ...
66
+ path('live-logs/', include('django_live_logs.urls')),
67
+ ]
68
+ ```
69
+
70
+ ### Step C: Route the WebSockets (ASGI)
71
+ In your `asgi.py`, make sure your WebSockets are wrapped in Django's `AuthMiddlewareStack` so it can read your admin session cookies securely. Then include the routing:
72
+
73
+ ```python
74
+ from channels.auth import AuthMiddlewareStack
75
+ import django_live_logs.routing
76
+
77
+ application = ProtocolTypeRouter({
78
+ "http": get_asgi_application(),
79
+ "websocket": AllowedHostsOriginValidator(
80
+ AuthMiddlewareStack(
81
+ URLRouter(
82
+ # Your existing websocket routes + Live Logs routes
83
+ your_app_routing.websocket_urlpatterns +
84
+ django_live_logs.routing.websocket_urlpatterns
85
+ )
86
+ )
87
+ ),
88
+ })
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 3. Usage
94
+
95
+ 1. Log into your standard Django Admin panel (e.g., `/admin/`).
96
+ 2. Navigate to `/live-logs/` in your browser.
97
+ 3. Click **Connect**.
98
+ 4. Watch your server logs flow in real-time!
99
+
100
+ ## How it Works
101
+ The custom `WebSocketLogHandler` intercepts Python logs. Instead of blocking the main thread, it tosses the log payload to a daemon thread. The daemon thread safely uses `async_to_sync` to dispatch the JSON payload to the Redis Channel Layer. Finally, the Django Channels WebSocket consumer securely beams it to the authenticated frontend UI.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,38 @@
1
+ from channels.generic.websocket import AsyncJsonWebsocketConsumer
2
+ from django.conf import settings
3
+
4
+ class LiveLogConsumer(AsyncJsonWebsocketConsumer):
5
+ async def connect(self):
6
+ # Allow configuration of who can view logs. Defaults to superusers only.
7
+ require_superuser = getattr(settings, 'LIVE_LOGS_REQUIRE_SUPERUSER', True)
8
+
9
+ user = self.scope.get("user")
10
+ if require_superuser and (not user or not user.is_superuser):
11
+ await self.close(code=4003)
12
+ return
13
+
14
+ self.group_name = "admin_live_logs"
15
+ await self.channel_layer.group_add(self.group_name, self.channel_name)
16
+ await self.accept()
17
+
18
+ # Send an initial welcome message so the client knows it's connected
19
+ await self.send_json({
20
+ "level": "INFO",
21
+ "message": "Connected to django-live-logs stream.",
22
+ "module": "system",
23
+ })
24
+
25
+ async def disconnect(self, close_code):
26
+ if hasattr(self, 'group_name'):
27
+ await self.channel_layer.group_discard(self.group_name, self.channel_name)
28
+
29
+ async def log_message(self, event):
30
+ """
31
+ Receives the log from the Redis channel and sends it down the WebSocket
32
+ """
33
+ await self.send_json({
34
+ "level": event.get("level"),
35
+ "message": event.get("message"),
36
+ "module": event.get("module"),
37
+ "timestamp": event.get("timestamp")
38
+ })
@@ -0,0 +1,40 @@
1
+ import logging
2
+ from asgiref.sync import async_to_sync
3
+ from channels.layers import get_channel_layer
4
+ import datetime
5
+ import threading
6
+
7
+ def _send_to_channel(channel_layer, data):
8
+ try:
9
+ async_to_sync(channel_layer.group_send)("admin_live_logs", data)
10
+ except Exception as e:
11
+ print("LIVE LOGS BACKGROUND ERROR:", e)
12
+
13
+ class WebSocketLogHandler(logging.Handler):
14
+ """
15
+ Custom logging handler that intercepts logs and broadcasts them
16
+ to a Redis channel layer for real-time WebSocket streaming.
17
+ """
18
+ def emit(self, record):
19
+ try:
20
+ log_entry = self.format(record)
21
+ channel_layer = get_channel_layer()
22
+
23
+ if not channel_layer:
24
+ return
25
+
26
+ data = {
27
+ "type": "log_message",
28
+ "level": record.levelname,
29
+ "message": log_entry,
30
+ "module": record.module,
31
+ "timestamp": datetime.datetime.now().isoformat()
32
+ }
33
+
34
+ # Run in a separate thread so it never blocks the main app
35
+ # and avoids ASGI async_to_sync event loop conflicts.
36
+ threading.Thread(target=_send_to_channel, args=(channel_layer, data), daemon=True).start()
37
+
38
+ except Exception as e:
39
+ # We must never crash the application just because a log failed to send
40
+ print("LIVE LOGS ERROR:", e)
@@ -0,0 +1,6 @@
1
+ from django.urls import re_path
2
+ from . import consumers
3
+
4
+ websocket_urlpatterns = [
5
+ re_path(r'^ws/live-logs/$', consumers.LiveLogConsumer.as_asgi()),
6
+ ]
@@ -0,0 +1,226 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Django Live Logs</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ darkMode: 'class',
11
+ theme: {
12
+ extend: {
13
+ colors: {
14
+ gray: {
15
+ 900: '#0f172a',
16
+ 800: '#1e293b',
17
+ 700: '#334155'
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ </script>
24
+ <style>
25
+ /* Custom scrollbar */
26
+ ::-webkit-scrollbar { width: 8px; }
27
+ ::-webkit-scrollbar-track { background: #0f172a; }
28
+ ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
29
+ ::-webkit-scrollbar-thumb:hover { background: #475569; }
30
+ .log-enter { animation: slideIn 0.3s ease-out forwards; }
31
+ @keyframes slideIn {
32
+ from { opacity: 0; transform: translateY(10px); }
33
+ to { opacity: 1; transform: translateY(0); }
34
+ }
35
+ </style>
36
+ </head>
37
+ <body class="bg-gray-900 text-gray-100 min-h-screen font-mono flex flex-col">
38
+
39
+ <!-- Header -->
40
+ <header class="bg-gray-800 border-b border-gray-700 p-4 shadow-lg sticky top-0 z-10">
41
+ <div class="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
42
+ <div class="flex items-center gap-3">
43
+ <div class="relative flex h-3 w-3">
44
+ <span id="status-ping" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
45
+ <span id="status-dot" class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
46
+ </div>
47
+ <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-emerald-400">
48
+ Django Live Logs
49
+ </h1>
50
+ </div>
51
+
52
+ <!-- Connection Controls -->
53
+ <div class="flex w-full md:w-auto items-center gap-3">
54
+ <button id="connect-btn" onclick="toggleConnection()"
55
+ class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-1.5 rounded text-sm font-semibold transition-colors whitespace-nowrap shadow">
56
+ Connect
57
+ </button>
58
+ </div>
59
+ </div>
60
+ </header>
61
+
62
+ <!-- Controls Bar -->
63
+ <div class="bg-gray-800/50 border-b border-gray-700 p-2">
64
+ <div class="max-w-7xl mx-auto flex justify-between items-center px-2">
65
+ <div class="flex gap-2 text-xs">
66
+ <button onclick="filterLogs('ALL')" class="filter-btn bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded transition-colors active ring-1 ring-white" data-level="ALL">All</button>
67
+ <button onclick="filterLogs('INFO')" class="filter-btn bg-blue-900/50 text-blue-300 hover:bg-blue-800/50 px-3 py-1 rounded transition-colors" data-level="INFO">Info</button>
68
+ <button onclick="filterLogs('WARNING')" class="filter-btn bg-amber-900/50 text-amber-300 hover:bg-amber-800/50 px-3 py-1 rounded transition-colors" data-level="WARNING">Warn</button>
69
+ <button onclick="filterLogs('ERROR')" class="filter-btn bg-red-900/50 text-red-300 hover:bg-red-800/50 px-3 py-1 rounded transition-colors" data-level="ERROR">Error</button>
70
+ </div>
71
+ <button onclick="clearLogs()" class="text-gray-400 hover:text-white text-xs px-3 py-1 rounded hover:bg-gray-700 transition-colors">
72
+ Clear Logs
73
+ </button>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Logs Container -->
78
+ <main class="flex-1 overflow-auto p-4 max-w-7xl w-full mx-auto" id="logs-container">
79
+ <!-- Waiting State -->
80
+ <div id="waiting-state" class="flex flex-col items-center justify-center h-full text-gray-500 space-y-4 pt-20">
81
+ <svg class="w-16 h-16 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 9l3 3-3 3m5 0h3M4 15V9a2 2 0 012-2h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2z"></path></svg>
82
+ <p>Click Connect to start streaming server logs in real-time.</p>
83
+ </div>
84
+ </main>
85
+
86
+ <script>
87
+ let ws = null;
88
+ let activeFilter = 'ALL';
89
+ const logsContainer = document.getElementById('logs-container');
90
+ const waitingState = document.getElementById('waiting-state');
91
+ const connectBtn = document.getElementById('connect-btn');
92
+ const statusPing = document.getElementById('status-ping');
93
+ const statusDot = document.getElementById('status-dot');
94
+
95
+ function getLevelColors(level) {
96
+ switch(level) {
97
+ case 'INFO': return 'text-blue-400 border-blue-900/50 bg-blue-900/10';
98
+ case 'WARNING': return 'text-amber-400 border-amber-900/50 bg-amber-900/10';
99
+ case 'ERROR': return 'text-red-400 border-red-900/50 bg-red-900/10';
100
+ case 'CRITICAL': return 'text-purple-400 border-purple-900/50 bg-purple-900/10 font-bold';
101
+ default: return 'text-gray-300 border-gray-700 bg-gray-800/30';
102
+ }
103
+ }
104
+
105
+ function filterLogs(level) {
106
+ activeFilter = level;
107
+
108
+ // Update buttons
109
+ document.querySelectorAll('.filter-btn').forEach(btn => {
110
+ if (btn.dataset.level === level) {
111
+ btn.classList.add('ring-1', 'ring-white');
112
+ } else {
113
+ btn.classList.remove('ring-1', 'ring-white');
114
+ }
115
+ });
116
+
117
+ // Update visible logs
118
+ document.querySelectorAll('.log-entry').forEach(entry => {
119
+ if (level === 'ALL' || entry.dataset.level === level) {
120
+ entry.style.display = 'block';
121
+ } else {
122
+ entry.style.display = 'none';
123
+ }
124
+ });
125
+ }
126
+
127
+ function clearLogs() {
128
+ document.querySelectorAll('.log-entry').forEach(e => e.remove());
129
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
130
+ waitingState.style.display = 'flex';
131
+ }
132
+ }
133
+
134
+ function addLog(data) {
135
+ waitingState.style.display = 'none';
136
+ const colors = getLevelColors(data.level);
137
+
138
+ // Format timestamp if it exists, otherwise use current time
139
+ let timeStr = new Date().toLocaleTimeString();
140
+ if (data.timestamp) {
141
+ const d = new Date(data.timestamp);
142
+ timeStr = d.toLocaleTimeString() + '.' + d.getMilliseconds().toString().padStart(3, '0');
143
+ }
144
+
145
+ const div = document.createElement('div');
146
+ div.className = `log-entry log-enter border-l-4 rounded p-3 mb-2 text-sm shadow-sm ${colors}`;
147
+ div.dataset.level = data.level;
148
+
149
+ if (activeFilter !== 'ALL' && activeFilter !== data.level) {
150
+ div.style.display = 'none';
151
+ }
152
+
153
+ div.innerHTML = `
154
+ <div class="flex justify-between items-start mb-1">
155
+ <div class="flex gap-2 text-xs font-semibold opacity-75">
156
+ <span class="px-1.5 py-0.5 rounded bg-gray-900/50">${data.level}</span>
157
+ <span class="px-1.5 py-0.5 rounded bg-gray-900/50">${data.module || 'system'}</span>
158
+ </div>
159
+ <span class="text-xs opacity-50">${timeStr}</span>
160
+ </div>
161
+ <div class="whitespace-pre-wrap break-words leading-relaxed">${data.message}</div>
162
+ `;
163
+
164
+ logsContainer.appendChild(div);
165
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
166
+
167
+ // Keep only last 500 logs to prevent memory leaks
168
+ if (logsContainer.children.length > 501) { // +1 for waiting state
169
+ logsContainer.removeChild(logsContainer.children[1]);
170
+ }
171
+ }
172
+
173
+ function toggleConnection() {
174
+ if (ws && ws.readyState === WebSocket.OPEN) {
175
+ ws.close();
176
+ return;
177
+ }
178
+
179
+ connectBtn.textContent = 'Connecting...';
180
+ connectBtn.classList.replace('bg-blue-600', 'bg-amber-600');
181
+
182
+ // Determine WS URL automatically
183
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
184
+ const wsUrl = `${protocol}//${window.location.host}/ws/live-logs/`;
185
+
186
+ ws = new WebSocket(wsUrl);
187
+
188
+ ws.onopen = () => {
189
+ connectBtn.textContent = 'Disconnect';
190
+ connectBtn.classList.replace('bg-amber-600', 'bg-red-600');
191
+ connectBtn.classList.replace('bg-blue-600', 'bg-red-600');
192
+ statusPing.classList.replace('bg-red-400', 'bg-emerald-400');
193
+ statusDot.classList.replace('bg-red-500', 'bg-emerald-500');
194
+ };
195
+
196
+ ws.onmessage = (event) => {
197
+ try {
198
+ const data = JSON.parse(event.data);
199
+ addLog(data);
200
+ } catch (e) {
201
+ console.error("Failed to parse log message:", event.data);
202
+ }
203
+ };
204
+
205
+ ws.onclose = (event) => {
206
+ connectBtn.textContent = 'Connect';
207
+ connectBtn.classList.replace('bg-red-600', 'bg-blue-600');
208
+ connectBtn.classList.replace('bg-amber-600', 'bg-blue-600');
209
+ statusPing.classList.replace('bg-emerald-400', 'bg-red-400');
210
+ statusDot.classList.replace('bg-emerald-500', 'bg-red-500');
211
+
212
+ if (event.code === 4003) {
213
+ addLog({ level: 'ERROR', message: 'Connection Rejected: Invalid Token or Not a Superuser', module: 'system' });
214
+ } else if (!event.wasClean) {
215
+ addLog({ level: 'WARNING', message: 'Connection Lost. Retrying...', module: 'system' });
216
+ }
217
+ ws = null;
218
+ };
219
+
220
+ ws.onerror = (error) => {
221
+ console.error("WebSocket Error:", error);
222
+ };
223
+ }
224
+ </script>
225
+ </body>
226
+ </html>
@@ -0,0 +1,6 @@
1
+ from django.urls import path
2
+ from . import views
3
+
4
+ urlpatterns = [
5
+ path('live-logs/', views.live_logs_dashboard, name='live_logs_dashboard'),
6
+ ]
@@ -0,0 +1,11 @@
1
+ from django.shortcuts import render
2
+ from django.conf import settings
3
+ from django.contrib.admin.views.decorators import staff_member_required
4
+
5
+ @staff_member_required
6
+ def live_logs_dashboard(request):
7
+ """
8
+ Renders the live log dashboard UI.
9
+ Requires the user to be logged into the standard Django admin.
10
+ """
11
+ return render(request, 'django_live_logs/dashboard.html')
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-live-logs
3
+ Version: 0.1.0
4
+ Summary: A standalone Django package to stream logs over WebSockets.
5
+ Author: Your Name
6
+ Requires-Dist: Django>=3.2
7
+ Requires-Dist: channels>=4.0
8
+ Dynamic: author
9
+ Dynamic: requires-dist
10
+ Dynamic: summary
@@ -0,0 +1,15 @@
1
+ MANIFEST.in
2
+ README.md
3
+ setup.py
4
+ django_live_logs/__init__.py
5
+ django_live_logs/consumers.py
6
+ django_live_logs/handlers.py
7
+ django_live_logs/routing.py
8
+ django_live_logs/urls.py
9
+ django_live_logs/views.py
10
+ django_live_logs.egg-info/PKG-INFO
11
+ django_live_logs.egg-info/SOURCES.txt
12
+ django_live_logs.egg-info/dependency_links.txt
13
+ django_live_logs.egg-info/requires.txt
14
+ django_live_logs.egg-info/top_level.txt
15
+ django_live_logs/templates/django_live_logs/dashboard.html
@@ -0,0 +1,2 @@
1
+ Django>=3.2
2
+ channels>=4.0
@@ -0,0 +1 @@
1
+ django_live_logs
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="django-live-logs",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ include_package_data=True,
8
+ install_requires=[
9
+ "Django>=3.2",
10
+ "channels>=4.0",
11
+ ],
12
+ author="Your Name",
13
+ description="A standalone Django package to stream logs over WebSockets.",
14
+ )