python-flashapi 0.1.2__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- flashapi/__init__.py +8 -7
- flashapi/adapters/base.py +13 -12
- flashapi/adapters/django.py +808 -165
- flashapi/adapters/fastapi.py +911 -293
- flashapi/adapters/flask.py +762 -232
- flashapi/core/__init__.py +11 -3
- flashapi/core/custom_routes.py +5 -5
- flashapi/core/pluralize.py +80 -80
- flashapi/core/relations.py +59 -61
- flashapi/core/response.py +36 -33
- flashapi/core/schema.py +145 -83
- flashapi/core/visibility.py +46 -0
- flashapi/django.py +5 -5
- flashapi/docs/openapi.py +298 -223
- flashapi/fastapi.py +5 -5
- flashapi/features/__init__.py +6 -6
- flashapi/features/audit.py +84 -0
- flashapi/features/auth.py +134 -0
- flashapi/features/dashboard.py +412 -0
- flashapi/features/export.py +143 -0
- flashapi/features/filtering.py +124 -33
- flashapi/features/health.py +69 -0
- flashapi/features/pagination.py +20 -20
- flashapi/features/rate_limit.py +45 -0
- flashapi/features/search.py +25 -25
- flashapi/features/sorting.py +23 -21
- flashapi/features/webhooks.py +84 -0
- flashapi/features/websocket.py +129 -0
- flashapi/flask.py +5 -5
- flashapi/inspectors/__init__.py +3 -3
- flashapi/inspectors/base.py +13 -11
- flashapi/inspectors/dataclass.py +50 -39
- flashapi/inspectors/detect.py +54 -48
- flashapi/inspectors/django.py +93 -83
- flashapi/inspectors/pydantic.py +99 -84
- flashapi/inspectors/sqlalchemy.py +83 -75
- flashapi/storage/__init__.py +4 -4
- flashapi/storage/auto.py +189 -106
- flashapi/storage/base.py +32 -26
- flashapi/storage/orm.py +125 -85
- flashapi/storage/sqlalchemy.py +56 -13
- python_flashapi-0.3.0.dist-info/METADATA +319 -0
- python_flashapi-0.3.0.dist-info/RECORD +49 -0
- {python_flashapi-0.1.2.dist-info → python_flashapi-0.3.0.dist-info}/WHEEL +1 -1
- python_flashapi-0.3.0.dist-info/licenses/LICENSE +190 -0
- python_flashapi-0.3.0.dist-info/licenses/NOTICE +5 -0
- python_flashapi-0.1.2.dist-info/METADATA +0 -259
- python_flashapi-0.1.2.dist-info/RECORD +0 -39
- python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Audit trail — records CREATE, UPDATE, DELETE with field diffs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuditLog:
|
|
14
|
+
"""SQLite-backed audit trail."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, conn: sqlite3.Connection, table_name: str = "flash_audit_log") -> None:
|
|
17
|
+
self._conn = conn
|
|
18
|
+
self._table = table_name
|
|
19
|
+
self._ensure_table()
|
|
20
|
+
|
|
21
|
+
def _ensure_table(self) -> None:
|
|
22
|
+
self._conn.execute(f"""
|
|
23
|
+
CREATE TABLE IF NOT EXISTS "{self._table}" (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
action TEXT NOT NULL,
|
|
26
|
+
entity_type TEXT NOT NULL,
|
|
27
|
+
entity_id TEXT NOT NULL,
|
|
28
|
+
timestamp TEXT NOT NULL,
|
|
29
|
+
performed_by TEXT DEFAULT '',
|
|
30
|
+
changes TEXT
|
|
31
|
+
)
|
|
32
|
+
""")
|
|
33
|
+
self._conn.commit()
|
|
34
|
+
|
|
35
|
+
def record(
|
|
36
|
+
self,
|
|
37
|
+
action: str,
|
|
38
|
+
entity_type: str,
|
|
39
|
+
entity_id: str | int,
|
|
40
|
+
*,
|
|
41
|
+
performed_by: str = "",
|
|
42
|
+
old_data: dict | None = None,
|
|
43
|
+
new_data: dict | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
changes = None
|
|
46
|
+
if action == "UPDATE" and old_data and new_data:
|
|
47
|
+
changes = self._compute_diff(old_data, new_data)
|
|
48
|
+
|
|
49
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
50
|
+
self._conn.execute(
|
|
51
|
+
f'INSERT INTO "{self._table}" (action, entity_type, entity_id, timestamp, performed_by, changes) VALUES (?, ?, ?, ?, ?, ?)',
|
|
52
|
+
(action, entity_type, str(entity_id), now, performed_by, json.dumps(changes) if changes else None),
|
|
53
|
+
)
|
|
54
|
+
self._conn.commit()
|
|
55
|
+
|
|
56
|
+
def get_history(self, entity_type: str, entity_id: str | int) -> list[dict[str, Any]]:
|
|
57
|
+
cursor = self._conn.execute(
|
|
58
|
+
f'SELECT * FROM "{self._table}" WHERE entity_type = ? AND entity_id = ? ORDER BY timestamp ASC',
|
|
59
|
+
(entity_type, str(entity_id)),
|
|
60
|
+
)
|
|
61
|
+
results = []
|
|
62
|
+
for row in cursor.fetchall():
|
|
63
|
+
entry = {
|
|
64
|
+
"action": row["action"],
|
|
65
|
+
"entityType": row["entity_type"],
|
|
66
|
+
"entityId": row["entity_id"],
|
|
67
|
+
"timestamp": row["timestamp"],
|
|
68
|
+
"performedBy": row["performed_by"],
|
|
69
|
+
"changes": json.loads(row["changes"]) if row["changes"] else None,
|
|
70
|
+
}
|
|
71
|
+
results.append(entry)
|
|
72
|
+
return results
|
|
73
|
+
|
|
74
|
+
def _compute_diff(self, old: dict, new: dict) -> dict:
|
|
75
|
+
diff = {}
|
|
76
|
+
all_keys = set(old.keys()) | set(new.keys())
|
|
77
|
+
for key in all_keys:
|
|
78
|
+
if key == "id":
|
|
79
|
+
continue
|
|
80
|
+
old_val = old.get(key)
|
|
81
|
+
new_val = new.get(key)
|
|
82
|
+
if old_val != new_val:
|
|
83
|
+
diff[key] = {"from": old_val, "to": new_val}
|
|
84
|
+
return diff or None
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Authentication & authorization guard for FlashAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthBackend(ABC):
|
|
10
|
+
"""Interface that the developer implements with their auth stack.
|
|
11
|
+
|
|
12
|
+
FlashAPI never handles login, tokens, passwords, or OAuth flows.
|
|
13
|
+
It only asks: who is this user, and what can they do?
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def authenticate(self, request) -> Any | None:
|
|
18
|
+
"""Return the user object if authenticated, None otherwise.
|
|
19
|
+
|
|
20
|
+
The request object is framework-specific (Django HttpRequest, Flask request, etc.).
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def get_role(self, user) -> str:
|
|
25
|
+
"""Return the user's role as a string.
|
|
26
|
+
|
|
27
|
+
Built-in roles (ordered by privilege):
|
|
28
|
+
- "public" — no authentication required
|
|
29
|
+
- "authenticated" — any logged-in user
|
|
30
|
+
- "staff" — elevated privileges
|
|
31
|
+
- "admin" — full access, bypasses scopes
|
|
32
|
+
|
|
33
|
+
Custom roles are supported — they're matched literally against `access` config.
|
|
34
|
+
"""
|
|
35
|
+
return "authenticated"
|
|
36
|
+
|
|
37
|
+
def get_tenant_id(self, user) -> Any | None:
|
|
38
|
+
"""Return the tenant identifier for this user (e.g., organization_id, school_id).
|
|
39
|
+
|
|
40
|
+
Return None if the user has no tenant (e.g., superadmin).
|
|
41
|
+
"""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def get_owner_id(self, user) -> Any | None:
|
|
45
|
+
"""Return the owner identifier for this user (typically user.pk or user.id).
|
|
46
|
+
|
|
47
|
+
Used for scope="owner" models where each user only sees their own data.
|
|
48
|
+
"""
|
|
49
|
+
return getattr(user, "pk", None) or getattr(user, "id", None)
|
|
50
|
+
|
|
51
|
+
def get_user_identifier(self, user) -> str:
|
|
52
|
+
"""Return a display identifier for audit trail (e.g., username, email).
|
|
53
|
+
|
|
54
|
+
Override to customize. Default tries common attributes.
|
|
55
|
+
"""
|
|
56
|
+
for attr in ("username", "email", "name"):
|
|
57
|
+
val = getattr(user, attr, None)
|
|
58
|
+
if val:
|
|
59
|
+
return str(val)
|
|
60
|
+
return str(user)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Access levels ordered by privilege
|
|
64
|
+
ROLE_HIERARCHY = ["public", "authenticated", "staff", "admin"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_access(user_role: str, required_access: str | dict | bool | None, operation: str) -> bool:
|
|
68
|
+
"""Check if a user role satisfies the required access for an operation.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
user_role: The role of the current user ("public" if unauthenticated).
|
|
72
|
+
required_access: The access requirement, can be:
|
|
73
|
+
- None or "public" or True: no restriction
|
|
74
|
+
- "authenticated", "staff", "admin": minimum role required
|
|
75
|
+
- dict mapping operation -> role: per-operation control
|
|
76
|
+
- False: block all access
|
|
77
|
+
operation: The CRUD operation being performed ("list", "read", "create", "update", "delete").
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if access is granted, False otherwise.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
if required_access is None or required_access is True or required_access == "public":
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if required_access is False:
|
|
87
|
+
return user_role == "admin"
|
|
88
|
+
|
|
89
|
+
if isinstance(required_access, dict):
|
|
90
|
+
op_access = required_access.get(operation, "authenticated")
|
|
91
|
+
return check_access(user_role, op_access, operation)
|
|
92
|
+
|
|
93
|
+
if isinstance(required_access, str):
|
|
94
|
+
if required_access not in ROLE_HIERARCHY:
|
|
95
|
+
return user_role in (required_access, "admin")
|
|
96
|
+
required_level = ROLE_HIERARCHY.index(required_access)
|
|
97
|
+
if user_role not in ROLE_HIERARCHY:
|
|
98
|
+
return False
|
|
99
|
+
user_level = ROLE_HIERARCHY.index(user_role)
|
|
100
|
+
return user_level >= required_level
|
|
101
|
+
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_scope_filter(
|
|
106
|
+
user,
|
|
107
|
+
auth_backend: AuthBackend,
|
|
108
|
+
scope: str | None,
|
|
109
|
+
tenant_field: str | None,
|
|
110
|
+
owner_field: str | None,
|
|
111
|
+
user_role: str,
|
|
112
|
+
) -> dict[str, Any] | None:
|
|
113
|
+
"""Compute the filter dict to apply for multi-tenancy/ownership.
|
|
114
|
+
|
|
115
|
+
Returns None if no filtering is needed (admin or no scope).
|
|
116
|
+
Returns a dict like {"ecole_id": 5} or {"enseignant_id": 12}.
|
|
117
|
+
Scope "both" combines tenant AND owner filters (both must match).
|
|
118
|
+
"""
|
|
119
|
+
if scope is None or user_role == "admin":
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
filters = {}
|
|
123
|
+
|
|
124
|
+
if scope in ("tenant", "both"):
|
|
125
|
+
tenant_id = auth_backend.get_tenant_id(user)
|
|
126
|
+
if tenant_id is not None and tenant_field:
|
|
127
|
+
filters[tenant_field] = tenant_id
|
|
128
|
+
|
|
129
|
+
if scope in ("owner", "both"):
|
|
130
|
+
owner_id = auth_backend.get_owner_id(user)
|
|
131
|
+
if owner_id is not None and owner_field:
|
|
132
|
+
filters[owner_field] = owner_id
|
|
133
|
+
|
|
134
|
+
return filters or None
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Dashboard — HTML UI + JSON metrics endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetricsCollector:
|
|
12
|
+
"""Thread-safe metrics collector for FlashAPI operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._start_time = time.time()
|
|
16
|
+
self._entity_ops: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
17
|
+
self._totals: dict[str, int] = defaultdict(int)
|
|
18
|
+
self._recent_events: list[dict] = []
|
|
19
|
+
self._max_recent = 50
|
|
20
|
+
self._entity_meta: dict[str, dict[str, Any]] = {}
|
|
21
|
+
|
|
22
|
+
def register_entity(self, name: str, *, soft_delete: bool = True, audit: bool = False,
|
|
23
|
+
webhook: bool = False, rate_limited: bool = False,
|
|
24
|
+
multi_tenant: bool = False) -> None:
|
|
25
|
+
self._entity_meta[name] = {
|
|
26
|
+
"softDelete": soft_delete,
|
|
27
|
+
"auditEnabled": audit,
|
|
28
|
+
"webhookEnabled": webhook,
|
|
29
|
+
"rateLimited": rate_limited,
|
|
30
|
+
"multiTenant": multi_tenant,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def record(self, operation: str, entity: str, entity_id: str = "") -> None:
|
|
34
|
+
self._entity_ops[entity][operation] += 1
|
|
35
|
+
if operation == "CREATE":
|
|
36
|
+
self._totals["creates"] += 1
|
|
37
|
+
elif operation == "READ":
|
|
38
|
+
self._totals["reads"] += 1
|
|
39
|
+
elif operation == "UPDATE":
|
|
40
|
+
self._totals["updates"] += 1
|
|
41
|
+
elif operation == "DELETE":
|
|
42
|
+
self._totals["deletes"] += 1
|
|
43
|
+
elif operation == "SEARCH":
|
|
44
|
+
self._totals["searches"] += 1
|
|
45
|
+
elif operation == "EXPORT":
|
|
46
|
+
self._totals["exports"] += 1
|
|
47
|
+
elif operation == "BULK":
|
|
48
|
+
self._totals["bulkOps"] += 1
|
|
49
|
+
self._totals["total"] += 1
|
|
50
|
+
|
|
51
|
+
event = {
|
|
52
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
53
|
+
"operation": operation,
|
|
54
|
+
"entity": entity,
|
|
55
|
+
"entityId": str(entity_id),
|
|
56
|
+
"status": "OK",
|
|
57
|
+
}
|
|
58
|
+
self._recent_events.append(event)
|
|
59
|
+
if len(self._recent_events) > self._max_recent:
|
|
60
|
+
self._recent_events = self._recent_events[-self._max_recent:]
|
|
61
|
+
|
|
62
|
+
def get_metrics(self, webhook_dispatcher=None) -> dict:
|
|
63
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
64
|
+
uptime = int(time.time() - self._start_time)
|
|
65
|
+
|
|
66
|
+
entities = {}
|
|
67
|
+
for name, meta in self._entity_meta.items():
|
|
68
|
+
ops = dict(self._entity_ops.get(name, {}))
|
|
69
|
+
count = ops.get("CREATE", 0) - ops.get("DELETE", 0)
|
|
70
|
+
entities[name] = {
|
|
71
|
+
"name": name,
|
|
72
|
+
"count": max(0, count),
|
|
73
|
+
**meta,
|
|
74
|
+
"operations": {
|
|
75
|
+
"CREATE": ops.get("CREATE", 0),
|
|
76
|
+
"READ": ops.get("READ", 0),
|
|
77
|
+
"UPDATE": ops.get("UPDATE", 0),
|
|
78
|
+
"DELETE": ops.get("DELETE", 0),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
webhooks = {"sent": 0, "failed": 0, "retries": 0, "targetUrls": []}
|
|
83
|
+
if webhook_dispatcher:
|
|
84
|
+
webhooks = {
|
|
85
|
+
"sent": webhook_dispatcher.sent,
|
|
86
|
+
"failed": webhook_dispatcher.failed,
|
|
87
|
+
"retries": webhook_dispatcher.retries,
|
|
88
|
+
"targetUrls": webhook_dispatcher._urls,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"generatedAt": now,
|
|
93
|
+
"uptimeSeconds": uptime,
|
|
94
|
+
"entities": entities,
|
|
95
|
+
"totals": dict(self._totals),
|
|
96
|
+
"webhooks": webhooks,
|
|
97
|
+
"recentEvents": self._recent_events[-20:],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
DASHBOARD_HTML = """<!DOCTYPE html>
|
|
102
|
+
<html lang="en" class="h-full">
|
|
103
|
+
<head>
|
|
104
|
+
<meta charset="utf-8">
|
|
105
|
+
<title>FlashAPI — Dashboard</title>
|
|
106
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
107
|
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
108
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
109
|
+
<script>
|
|
110
|
+
tailwind.config = {
|
|
111
|
+
darkMode: 'class',
|
|
112
|
+
theme: {
|
|
113
|
+
extend: {
|
|
114
|
+
colors: {
|
|
115
|
+
flash: {
|
|
116
|
+
50: '#eef7ff',
|
|
117
|
+
100: '#d9ecff',
|
|
118
|
+
200: '#bce0ff',
|
|
119
|
+
300: '#8ecdff',
|
|
120
|
+
400: '#59b0ff',
|
|
121
|
+
500: '#338dff',
|
|
122
|
+
600: '#1a6df5',
|
|
123
|
+
700: '#1457e1',
|
|
124
|
+
800: '#1746b6',
|
|
125
|
+
900: '#193d8f',
|
|
126
|
+
950: '#142757',
|
|
127
|
+
},
|
|
128
|
+
surface: {
|
|
129
|
+
50: '#f8fafc',
|
|
130
|
+
100: '#f1f5f9',
|
|
131
|
+
200: '#e2e8f0',
|
|
132
|
+
700: '#1e293b',
|
|
133
|
+
800: '#0f172a',
|
|
134
|
+
900: '#020617',
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
fontFamily: {
|
|
138
|
+
display: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
|
139
|
+
body: ['Inter', 'system-ui', 'sans-serif'],
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
146
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
|
147
|
+
<style>
|
|
148
|
+
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
149
|
+
.live-dot { animation: pulse-dot 2s ease-in-out infinite; }
|
|
150
|
+
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
151
|
+
.animate-row { animation: fade-in 0.3s ease-out; }
|
|
152
|
+
.op-bar { transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body class="h-full bg-surface-50 dark:bg-surface-900 font-body text-slate-800 dark:text-slate-200 transition-colors duration-300">
|
|
156
|
+
|
|
157
|
+
<!-- Shell -->
|
|
158
|
+
<div class="min-h-full flex flex-col">
|
|
159
|
+
|
|
160
|
+
<!-- Header -->
|
|
161
|
+
<header class="sticky top-0 z-50 backdrop-blur-md bg-white/80 dark:bg-surface-800/80 border-b border-slate-200/60 dark:border-slate-700/60">
|
|
162
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
|
|
163
|
+
<div class="flex items-center gap-3">
|
|
164
|
+
<div class="flex items-center gap-2">
|
|
165
|
+
<svg class="w-6 h-6 text-flash-600 dark:text-flash-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
|
166
|
+
<span class="font-display font-bold text-lg tracking-tight">FlashAPI</span>
|
|
167
|
+
</div>
|
|
168
|
+
<span class="hidden sm:inline text-xs font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded-full">Dashboard</span>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="flex items-center gap-4">
|
|
171
|
+
<div class="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
|
172
|
+
<span class="live-dot w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
|
173
|
+
<span id="uptime-badge">Live</span>
|
|
174
|
+
</div>
|
|
175
|
+
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" title="Toggle dark mode">
|
|
176
|
+
<svg id="icon-sun" class="w-4 h-4 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
|
177
|
+
<svg id="icon-moon" class="w-4 h-4 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</header>
|
|
182
|
+
|
|
183
|
+
<!-- Main content -->
|
|
184
|
+
<main class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
|
185
|
+
|
|
186
|
+
<!-- Totals row -->
|
|
187
|
+
<section id="totals-section" class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-3">
|
|
188
|
+
<!-- Filled by JS -->
|
|
189
|
+
</section>
|
|
190
|
+
|
|
191
|
+
<!-- Two-column layout -->
|
|
192
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
193
|
+
|
|
194
|
+
<!-- Entities (2/3) -->
|
|
195
|
+
<section class="lg:col-span-2 space-y-3">
|
|
196
|
+
<div class="flex items-center justify-between">
|
|
197
|
+
<h2 class="text-sm font-semibold text-slate-600 dark:text-slate-300 uppercase tracking-wider">Resources</h2>
|
|
198
|
+
<span id="entity-count" class="text-xs text-slate-400"></span>
|
|
199
|
+
</div>
|
|
200
|
+
<div id="entities-grid" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
201
|
+
<!-- Filled by JS -->
|
|
202
|
+
</div>
|
|
203
|
+
</section>
|
|
204
|
+
|
|
205
|
+
<!-- Sidebar (1/3) -->
|
|
206
|
+
<aside class="space-y-6">
|
|
207
|
+
<!-- Webhooks -->
|
|
208
|
+
<div class="rounded-xl bg-white dark:bg-surface-800 border border-slate-200/80 dark:border-slate-700/50 p-5">
|
|
209
|
+
<h2 class="text-sm font-semibold text-slate-600 dark:text-slate-300 uppercase tracking-wider mb-4">Webhooks</h2>
|
|
210
|
+
<div id="webhook-stats" class="space-y-3">
|
|
211
|
+
<!-- Filled by JS -->
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- Recent events -->
|
|
216
|
+
<div class="rounded-xl bg-white dark:bg-surface-800 border border-slate-200/80 dark:border-slate-700/50 p-5">
|
|
217
|
+
<div class="flex items-center justify-between mb-4">
|
|
218
|
+
<h2 class="text-sm font-semibold text-slate-600 dark:text-slate-300 uppercase tracking-wider">Activity</h2>
|
|
219
|
+
<span class="text-[10px] text-slate-400 font-medium">LAST 10</span>
|
|
220
|
+
</div>
|
|
221
|
+
<div id="events-list" class="space-y-1.5 max-h-80 overflow-y-auto">
|
|
222
|
+
<!-- Filled by JS -->
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</aside>
|
|
226
|
+
</div>
|
|
227
|
+
</main>
|
|
228
|
+
|
|
229
|
+
<!-- Footer -->
|
|
230
|
+
<footer class="border-t border-slate-200/60 dark:border-slate-700/60 py-3">
|
|
231
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between text-[11px] text-slate-400">
|
|
232
|
+
<span>Polling every 5s</span>
|
|
233
|
+
<span id="last-update"></span>
|
|
234
|
+
</div>
|
|
235
|
+
</footer>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<script>
|
|
239
|
+
(function() {
|
|
240
|
+
// Dark mode
|
|
241
|
+
const html = document.documentElement;
|
|
242
|
+
const stored = localStorage.getItem('flash-theme');
|
|
243
|
+
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
244
|
+
html.classList.add('dark');
|
|
245
|
+
}
|
|
246
|
+
document.getElementById('theme-toggle').addEventListener('click', () => {
|
|
247
|
+
html.classList.toggle('dark');
|
|
248
|
+
localStorage.setItem('flash-theme', html.classList.contains('dark') ? 'dark' : 'light');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Formatting
|
|
252
|
+
function fmtUptime(s) {
|
|
253
|
+
if (s < 60) return s + 's';
|
|
254
|
+
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
|
|
255
|
+
const h = Math.floor(s/3600);
|
|
256
|
+
const m = Math.floor((s%3600)/60);
|
|
257
|
+
return h + 'h ' + m + 'm';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const OP_COLORS = {
|
|
261
|
+
CREATE: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-400', bar: 'bg-emerald-500' },
|
|
262
|
+
READ: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-400', bar: 'bg-blue-500' },
|
|
263
|
+
UPDATE: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-400', bar: 'bg-amber-500' },
|
|
264
|
+
DELETE: { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-400', bar: 'bg-red-500' },
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const STAT_ICONS = {
|
|
268
|
+
creates: '<path d="M12 5v14M5 12h14"/>',
|
|
269
|
+
reads: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
270
|
+
updates: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
|
271
|
+
deletes: '<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
|
272
|
+
searches: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
273
|
+
exports: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
|
|
274
|
+
bulkOps: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/>',
|
|
275
|
+
total: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
function statCard(key, value) {
|
|
279
|
+
const icon = STAT_ICONS[key] || STAT_ICONS.total;
|
|
280
|
+
return `
|
|
281
|
+
<div class="rounded-lg bg-white dark:bg-surface-800 border border-slate-200/80 dark:border-slate-700/50 p-3 text-center">
|
|
282
|
+
<div class="flex items-center justify-center mb-1.5">
|
|
283
|
+
<svg class="w-3.5 h-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="font-display text-xl font-bold text-slate-900 dark:text-white">${value}</div>
|
|
286
|
+
<div class="text-[10px] font-medium text-slate-400 uppercase tracking-wide mt-0.5">${key}</div>
|
|
287
|
+
</div>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function entityCard(e) {
|
|
291
|
+
const ops = e.operations;
|
|
292
|
+
const total = ops.CREATE + ops.READ + ops.UPDATE + ops.DELETE;
|
|
293
|
+
const maxOp = Math.max(ops.CREATE, ops.READ, ops.UPDATE, ops.DELETE, 1);
|
|
294
|
+
|
|
295
|
+
function bar(op, count) {
|
|
296
|
+
const pct = Math.round((count / maxOp) * 100);
|
|
297
|
+
const c = OP_COLORS[op];
|
|
298
|
+
return `<div class="flex items-center gap-2">
|
|
299
|
+
<span class="w-6 text-[10px] font-medium ${c.text}">${op[0]}</span>
|
|
300
|
+
<div class="flex-1 h-1.5 rounded-full bg-slate-100 dark:bg-slate-700 overflow-hidden">
|
|
301
|
+
<div class="op-bar h-full rounded-full ${c.bar}" style="width:${pct}%"></div>
|
|
302
|
+
</div>
|
|
303
|
+
<span class="w-8 text-right text-[11px] font-display text-slate-500 dark:text-slate-400">${count}</span>
|
|
304
|
+
</div>`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function flag(label, enabled) {
|
|
308
|
+
if (enabled) return `<span class="px-1.5 py-0.5 text-[9px] font-semibold uppercase rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">${label}</span>`;
|
|
309
|
+
return '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const flags = [
|
|
313
|
+
flag('soft-del', e.softDelete),
|
|
314
|
+
flag('audit', e.auditEnabled),
|
|
315
|
+
flag('webhook', e.webhookEnabled),
|
|
316
|
+
flag('rate-limit', e.rateLimited),
|
|
317
|
+
flag('multi-tenant', e.multiTenant),
|
|
318
|
+
].filter(Boolean).join(' ');
|
|
319
|
+
|
|
320
|
+
return `
|
|
321
|
+
<div class="rounded-xl bg-white dark:bg-surface-800 border border-slate-200/80 dark:border-slate-700/50 p-4 space-y-3 hover:border-flash-300 dark:hover:border-flash-700 transition-colors">
|
|
322
|
+
<div class="flex items-center justify-between">
|
|
323
|
+
<h3 class="font-display font-semibold text-sm text-slate-900 dark:text-white">${e.name}</h3>
|
|
324
|
+
<span class="text-xs font-display text-slate-400">${total} ops</span>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="space-y-1.5">${bar('CREATE', ops.CREATE)}${bar('READ', ops.READ)}${bar('UPDATE', ops.UPDATE)}${bar('DELETE', ops.DELETE)}</div>
|
|
327
|
+
${flags ? '<div class="flex flex-wrap gap-1 pt-1">' + flags + '</div>' : ''}
|
|
328
|
+
</div>`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function webhookBlock(wh) {
|
|
332
|
+
const total = wh.sent + wh.failed;
|
|
333
|
+
const successRate = total > 0 ? Math.round((wh.sent / total) * 100) : 100;
|
|
334
|
+
const rateColor = successRate >= 95 ? 'text-emerald-600 dark:text-emerald-400' :
|
|
335
|
+
successRate >= 80 ? 'text-amber-600 dark:text-amber-400' :
|
|
336
|
+
'text-red-600 dark:text-red-400';
|
|
337
|
+
|
|
338
|
+
if (wh.targetUrls.length === 0) {
|
|
339
|
+
return `<p class="text-xs text-slate-400 italic">No webhook URLs configured</p>`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return `
|
|
343
|
+
<div class="flex items-baseline justify-between">
|
|
344
|
+
<span class="font-display text-2xl font-bold ${rateColor}">${successRate}%</span>
|
|
345
|
+
<span class="text-[10px] text-slate-400 uppercase">success rate</span>
|
|
346
|
+
</div>
|
|
347
|
+
<div class="grid grid-cols-3 gap-2 text-center">
|
|
348
|
+
<div><div class="font-display text-sm font-bold text-slate-700 dark:text-slate-200">${wh.sent}</div><div class="text-[9px] text-slate-400 uppercase">sent</div></div>
|
|
349
|
+
<div><div class="font-display text-sm font-bold text-slate-700 dark:text-slate-200">${wh.failed}</div><div class="text-[9px] text-slate-400 uppercase">failed</div></div>
|
|
350
|
+
<div><div class="font-display text-sm font-bold text-slate-700 dark:text-slate-200">${wh.retries}</div><div class="text-[9px] text-slate-400 uppercase">retries</div></div>
|
|
351
|
+
</div>
|
|
352
|
+
<div class="pt-2 border-t border-slate-100 dark:border-slate-700">
|
|
353
|
+
<div class="text-[10px] text-slate-400 uppercase mb-1">Targets</div>
|
|
354
|
+
${wh.targetUrls.map(u => `<div class="text-[11px] font-display text-slate-500 dark:text-slate-400 truncate">${u}</div>`).join('')}
|
|
355
|
+
</div>`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function eventRow(ev) {
|
|
359
|
+
const time = ev.timestamp.split('T')[1].split('.')[0];
|
|
360
|
+
const c = OP_COLORS[ev.operation] || OP_COLORS.READ;
|
|
361
|
+
return `
|
|
362
|
+
<div class="animate-row flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
|
363
|
+
<span class="font-display text-[10px] text-slate-400 w-12 shrink-0">${time}</span>
|
|
364
|
+
<span class="px-1.5 py-0.5 text-[9px] font-bold uppercase rounded ${c.bg} ${c.text} shrink-0">${ev.operation}</span>
|
|
365
|
+
<span class="text-xs text-slate-600 dark:text-slate-300 truncate">${ev.entity}</span>
|
|
366
|
+
<span class="ml-auto text-[10px] font-display text-slate-400">#${ev.entityId || '-'}</span>
|
|
367
|
+
</div>`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Fetch and render
|
|
371
|
+
async function load() {
|
|
372
|
+
try {
|
|
373
|
+
const base = window.location.pathname.replace(/\\/$/, '');
|
|
374
|
+
const r = await fetch(base + '/metrics.json');
|
|
375
|
+
const m = await r.json();
|
|
376
|
+
|
|
377
|
+
// Uptime
|
|
378
|
+
document.getElementById('uptime-badge').textContent = 'Up ' + fmtUptime(m.uptimeSeconds);
|
|
379
|
+
|
|
380
|
+
// Totals
|
|
381
|
+
const t = m.totals || {};
|
|
382
|
+
const keys = ['creates','reads','updates','deletes','searches','exports','bulkOps','total'];
|
|
383
|
+
document.getElementById('totals-section').innerHTML = keys.map(k => statCard(k, t[k] || 0)).join('');
|
|
384
|
+
|
|
385
|
+
// Entities
|
|
386
|
+
const entities = Object.values(m.entities || {});
|
|
387
|
+
document.getElementById('entity-count').textContent = entities.length + ' registered';
|
|
388
|
+
document.getElementById('entities-grid').innerHTML = entities.map(entityCard).join('');
|
|
389
|
+
|
|
390
|
+
// Webhooks
|
|
391
|
+
document.getElementById('webhook-stats').innerHTML = webhookBlock(m.webhooks || {sent:0, failed:0, retries:0, targetUrls:[]});
|
|
392
|
+
|
|
393
|
+
// Events
|
|
394
|
+
const events = (m.recentEvents || []).slice(-10).reverse();
|
|
395
|
+
document.getElementById('events-list').innerHTML = events.length > 0
|
|
396
|
+
? events.map(eventRow).join('')
|
|
397
|
+
: '<p class="text-xs text-slate-400 italic py-4 text-center">No activity yet</p>';
|
|
398
|
+
|
|
399
|
+
// Last update
|
|
400
|
+
document.getElementById('last-update').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
|
401
|
+
} catch(err) {
|
|
402
|
+
console.error('Dashboard fetch failed:', err);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Initial load + polling via HTMX-style interval
|
|
407
|
+
load();
|
|
408
|
+
setInterval(load, 5000);
|
|
409
|
+
})();
|
|
410
|
+
</script>
|
|
411
|
+
</body>
|
|
412
|
+
</html>"""
|