mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-07-26 07:28:39 -04:00
BE+FE: change log
This commit is contained in:
@@ -777,12 +777,12 @@ def api_devices_totals(payload=None):
|
||||
@validate_request(
|
||||
operation_id="get_device_history_filters",
|
||||
summary="Get Device History Filter Values",
|
||||
description="Return distinct changedBy and changedColumn values available in DevicesHistory. Optionally scope to a single device with ?devGuid=<guid>.",
|
||||
description="Return distinct changedBy and changedColumn values available in DevicesHistory. Optionally scope to a single device with ?devGUID=<guid>.",
|
||||
tags=["devices"],
|
||||
auth_callable=is_authorized
|
||||
)
|
||||
def api_devices_history_filters(payload=None):
|
||||
dev_guid = request.args.get("devGuid") or None
|
||||
dev_guid = request.args.get("devGUID") or None
|
||||
filters = DevicesHistoryInstance().get_available_filter_values(devGUID=dev_guid)
|
||||
return jsonify({"success": True, "data": filters})
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from .graphql_helpers import ( # noqa: E402 [flake8 lint suppression]
|
||||
apply_common_pagination,
|
||||
apply_plugin_filters,
|
||||
apply_events_filters,
|
||||
extract_paging
|
||||
)
|
||||
from models.device_history_instance import DevicesHistoryInstance # noqa: E402
|
||||
|
||||
@@ -65,72 +66,98 @@ def _to_graphql_history(groups):
|
||||
|
||||
|
||||
class Query(ObjectType):
|
||||
# --- DEVICES ---
|
||||
devices = Field(DeviceResult, options=PageQueryOptionsInput())
|
||||
|
||||
# --- DEVICE HISTORY ---
|
||||
deviceHistoryGrouped = Field(
|
||||
DeviceHistoryResult,
|
||||
devGuid=String(required=True, description="Device GUID to fetch history for"),
|
||||
devGUID=String(required=False, description="Device GUID to fetch history for"),
|
||||
changedColumn=String(description="Filter to groups containing this field change"),
|
||||
changedBy=String(description="Filter to a specific attribution source"),
|
||||
limit=graphene.Int(description="Max grouped events to return (default 50)"),
|
||||
offset=graphene.Int(description="Grouped-event offset for pagination (default 0)"),
|
||||
options=PageQueryOptionsInput(description="Paging, sorting, filtering options"),
|
||||
)
|
||||
|
||||
allDeviceHistoryGrouped = Field(
|
||||
DeviceHistoryResult,
|
||||
changedColumn=String(description="Filter to groups containing this field change"),
|
||||
changedBy=String(description="Filter to a specific attribution source"),
|
||||
limit=graphene.Int(description="Max grouped events to return (default 100)"),
|
||||
offset=graphene.Int(description="Grouped-event offset for pagination (default 0)"),
|
||||
)
|
||||
def resolve_deviceHistoryGrouped(
|
||||
self,
|
||||
info,
|
||||
devGUID=None,
|
||||
changedColumn=None,
|
||||
changedBy=None,
|
||||
options=None):
|
||||
"""
|
||||
Return grouped device history.
|
||||
|
||||
def resolve_deviceHistoryGrouped(self, info, devGuid, changedColumn=None,
|
||||
changedBy=None, limit=50, offset=0):
|
||||
If `devGUID` is provided, results are restricted to a single device.
|
||||
If omitted, history across all devices is returned.
|
||||
|
||||
This resolver supports:
|
||||
- Optional filtering by device GUID, change column, and attribution source
|
||||
- Pagination via `options` (page/limit/offset)
|
||||
- Sorting at the grouped event level
|
||||
- Full-text search across history fields (if enabled in backend)
|
||||
|
||||
Internally:
|
||||
- Raw history rows are fetched from the database
|
||||
- Rows are grouped in Python by (timestamp, changedBy, devGUID)
|
||||
- Sorting is applied to grouped events
|
||||
- Pagination is applied after grouping
|
||||
|
||||
Args:
|
||||
info: GraphQL execution context
|
||||
devGUID (str | None): Optional device GUID filter
|
||||
changedColumn (str | None): Filter to only events containing this column
|
||||
changedBy (str | None): Filter by attribution source
|
||||
options (PageQueryOptionsInput | None): Paging, sorting, and search options
|
||||
|
||||
Returns:
|
||||
DeviceHistoryResult: Grouped history events and total count
|
||||
"""
|
||||
try:
|
||||
mylog("none", f"[HISTORY] unified resolver devGUID={devGUID} changedColumn={changedColumn} changedBy={changedBy}")
|
||||
|
||||
h = DevicesHistoryInstance()
|
||||
groups = h.get_grouped_history(
|
||||
devGUID=devGuid,
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = h.get_total_group_count(
|
||||
devGUID=devGuid,
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
)
|
||||
paging = extract_paging(options)
|
||||
|
||||
if devGUID:
|
||||
groups = h.get_grouped_history(
|
||||
devGUID=devGUID,
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
limit=paging["limit"],
|
||||
offset=paging["offset"],
|
||||
sort=paging["sort"],
|
||||
search=paging["search"],
|
||||
)
|
||||
|
||||
total = h.get_total_group_count(
|
||||
devGUID=devGUID,
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
)
|
||||
else:
|
||||
groups = h.get_all_grouped_history(
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
limit=paging["limit"],
|
||||
offset=paging["offset"],
|
||||
sort=paging["sort"],
|
||||
search=paging["search"],
|
||||
)
|
||||
|
||||
total = h.get_total_group_count(
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
)
|
||||
|
||||
return DeviceHistoryResult(
|
||||
history=_to_graphql_history(groups),
|
||||
count=total,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
mylog("none", f"[graphql] resolve_deviceHistoryGrouped error: {e}")
|
||||
mylog("none", f"[graphql] unified resolver error: {e}")
|
||||
return DeviceHistoryResult(history=[], count=0)
|
||||
|
||||
def resolve_allDeviceHistoryGrouped(self, info, changedColumn=None,
|
||||
changedBy=None, limit=100, offset=0):
|
||||
try:
|
||||
h = DevicesHistoryInstance()
|
||||
groups = h.get_all_grouped_history(
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = h.get_total_group_count(
|
||||
changedColumn=changedColumn,
|
||||
changedBy=changedBy,
|
||||
)
|
||||
return DeviceHistoryResult(
|
||||
history=_to_graphql_history(groups),
|
||||
count=total,
|
||||
)
|
||||
except Exception as e:
|
||||
mylog("none", f"[graphql] resolve_allDeviceHistoryGrouped error: {e}")
|
||||
return DeviceHistoryResult(history=[], count=0)
|
||||
# --- DEVICES ---
|
||||
devices = Field(DeviceResult, options=PageQueryOptionsInput())
|
||||
|
||||
def resolve_devices(self, info, options=None):
|
||||
# mylog('none', f'[graphql_schema] resolve_devices: {self}')
|
||||
|
||||
@@ -139,3 +139,29 @@ def apply_events_filters(data, options):
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def extract_paging(options):
|
||||
if not options:
|
||||
return {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"sort": [],
|
||||
"search": None
|
||||
}
|
||||
|
||||
page = getattr(options, "page", 1) or 1
|
||||
limit = getattr(options, "limit", 50) or 50
|
||||
search = getattr(options, "search", None)
|
||||
sort = getattr(options, "sort", []) or []
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"sort": sort,
|
||||
"search": search
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from graphene import (
|
||||
|
||||
class SortOptionsInput(InputObjectType):
|
||||
field = String()
|
||||
order = String()
|
||||
order = String() # direction asc, desc
|
||||
|
||||
|
||||
class FilterOptionsInput(InputObjectType):
|
||||
@@ -20,8 +20,8 @@ class FilterOptionsInput(InputObjectType):
|
||||
|
||||
class PageQueryOptionsInput(InputObjectType):
|
||||
page = Int()
|
||||
limit = Int()
|
||||
sort = List(SortOptionsInput)
|
||||
limit = Int() # pageSize
|
||||
sort = List(SortOptionsInput) # sorting
|
||||
search = String()
|
||||
status = String()
|
||||
filters = List(FilterOptionsInput)
|
||||
|
||||
@@ -51,12 +51,6 @@ def _load_language_display_names():
|
||||
# ===============================================================================
|
||||
# Initialise user defined values
|
||||
# ===============================================================================
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
# Import user values
|
||||
# Check config dictionary
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
# managing application settings, ensuring SQL safety for user input, and updating internal configuration lists
|
||||
def ccd(
|
||||
@@ -75,6 +69,43 @@ def ccd(
|
||||
overriddenByEnv=0,
|
||||
all_plugins=[],
|
||||
):
|
||||
"""
|
||||
Create or update a configuration setting entry and synchronise it across
|
||||
in-memory config, plugin settings, and SQL-safe storage structures.
|
||||
|
||||
This function is responsible for:
|
||||
- Resolving a setting value from defaults or existing config
|
||||
- Sanitising text inputs for SQL safety
|
||||
- Propagating updates into plugin configuration structures when
|
||||
overridden by environment variables
|
||||
- Maintaining both raw and SQL-safe representations of settings
|
||||
- Storing optional JSON metadata alongside settings entries
|
||||
|
||||
Parameters:
|
||||
key (str): Unique identifier for the setting.
|
||||
default (any): Default value used if no existing config value exists.
|
||||
config_dir (dict): Dictionary holding current runtime configuration.
|
||||
name (str): Human-readable name of the setting.
|
||||
inputtype (str): Type of input (e.g. "text", "number", etc.).
|
||||
options (list|str): Allowed options or configuration constraints.
|
||||
group (str): Logical grouping/category of the setting.
|
||||
events (list, optional): Events triggered by changes to this setting.
|
||||
desc (str, optional): Human-readable description of the setting.
|
||||
setJsonMetadata (dict, optional): Additional metadata stored as JSON.
|
||||
overrideTemplate (dict, optional): Template overrides (currently unused).
|
||||
forceDefault (bool, optional): If True, ignores existing config value.
|
||||
overriddenByEnv (int, optional): Flag indicating environment override.
|
||||
all_plugins (list, optional): Plugin list for propagating updates.
|
||||
|
||||
Returns:
|
||||
any: The resolved configuration value after processing.
|
||||
|
||||
Side Effects:
|
||||
- Updates config_dir in-place
|
||||
- Updates conf.mySettings and conf.mySettingsSQLsafe
|
||||
- Updates plugin setting values if overridden by environment
|
||||
- Adds/updates metadata entries for non-metadata keys
|
||||
"""
|
||||
if events is None:
|
||||
events = []
|
||||
if setJsonMetadata is None:
|
||||
@@ -330,7 +361,7 @@ def importConfigs(pm, db, all_plugins):
|
||||
)
|
||||
conf.DEV_HIST_DAYS = ccd(
|
||||
"DEV_HIST_DAYS",
|
||||
14,
|
||||
1,
|
||||
c_d,
|
||||
"Device history retention (days)",
|
||||
'{"dataType":"integer", "elements": [{"elementType" : "input", "elementOptions" : [{"type": "number"}] ,"transformers": []}]}',
|
||||
@@ -338,25 +369,13 @@ def importConfigs(pm, db, all_plugins):
|
||||
"General",
|
||||
desc="Number of days to retain device change history. Set to 0 to completely disable the auditing engine.",
|
||||
)
|
||||
_DEV_HIST_TRACKED_DEFAULT = [
|
||||
'devMac', 'devName', 'devOwner', 'devType', 'devVendor', 'devFavorite',
|
||||
'devGroup', 'devComments', 'devLastIP', 'devFQDN', 'devPrimaryIPv4',
|
||||
'devPrimaryIPv6', 'devVlan', 'devForceStatus', 'devStaticIP', 'devScan',
|
||||
'devAlertDown', 'devCanSleep', 'devSkipRepeated', 'devLocation',
|
||||
'devIsArchived', 'devParentMAC', 'devParentPort', 'devParentRelType',
|
||||
'devReqNicsOnline', 'devIcon', 'devSite', 'devSSID', 'devSyncHubNode',
|
||||
'devSourcePlugin', 'devMacSource', 'devNameSource', 'devFQDNSource',
|
||||
'devLastIPSource', 'devVendorSource', 'devSSIDSource',
|
||||
'devParentMACSource', 'devParentPortSource', 'devParentRelTypeSource',
|
||||
'devVlanSource', 'devCustomProps',
|
||||
]
|
||||
conf.DEV_HIST_TRACKED = ccd(
|
||||
"DEV_HIST_TRACKED",
|
||||
_DEV_HIST_TRACKED_DEFAULT,
|
||||
['devMac', 'devName', 'devOwner', 'devType', 'devVendor', 'devFavorite', 'devGroup', 'devComments', 'devLastIP', 'devFQDN', 'devPrimaryIPv4', 'devPrimaryIPv6', 'devVlan', 'devForceStatus', 'devStaticIP', 'devScan', 'devAlertDown', 'devCanSleep', 'devSkipRepeated', 'devLocation', 'devIsArchived', 'devParentMAC', 'devParentPort', 'devParentRelType', 'devReqNicsOnline', 'devIcon', 'devSite', 'devSSID', 'devSyncHubNode'], # noqa: E501
|
||||
c_d,
|
||||
"Device history tracked fields",
|
||||
'{"dataType":"array","elements":[{"elementType":"select","elementHasInputValue":1,"elementOptions":[{"multiple":"true","orderable":"true"}],"transformers":[]},{"elementType":"button","elementOptions":[{"sourceSuffixes":[]},{"separator":""},{"cssClasses":"col-xs-12"},{"onClick":"selectChange(this)"},{"getStringKey":"Gen_Change"}],"transformers":[]}]}', # noqa: E501
|
||||
list_to_csv(_DEV_HIST_TRACKED_DEFAULT),
|
||||
list_to_csv(['devMac', 'devName', 'devOwner', 'devType', 'devVendor', 'devFavorite', 'devGroup', 'devComments', 'devLastIP', 'devFQDN', 'devPrimaryIPv4', 'devPrimaryIPv6', 'devVlan', 'devForceStatus', 'devStaticIP', 'devScan', 'devAlertDown', 'devCanSleep', 'devSkipRepeated', 'devLocation', 'devIsArchived', 'devParentMAC', 'devParentPort', 'devParentRelType', 'devReqNicsOnline', 'devIcon', 'devSite', 'devSSID', 'devSyncHubNode', 'devSourcePlugin', 'devMacSource', 'devNameSource', 'devFQDNSource', 'devLastIPSource', 'devVendorSource', 'devSSIDSource', 'devParentMACSource', 'devParentPortSource', 'devParentRelTypeSource', 'devVlanSource', 'devCustomProps']), # noqa: E501
|
||||
"General",
|
||||
desc="List of Devices column names to monitor for changes. Add or remove field names to control what gets audited.",
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ class DevicesHistoryInstance:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_grouped_history(self, devGUID, changedColumn=None, changedBy=None,
|
||||
limit=50, offset=0):
|
||||
limit=50, offset=0, sort=None, search=None):
|
||||
"""
|
||||
Return grouped change history for a single device.
|
||||
|
||||
@@ -60,10 +60,18 @@ class DevicesHistoryInstance:
|
||||
changedBy=changedBy,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sort=sort,
|
||||
search=search
|
||||
)
|
||||
|
||||
def get_all_grouped_history(self, changedColumn=None, changedBy=None,
|
||||
limit=100, offset=0):
|
||||
def get_all_grouped_history(self,
|
||||
changedColumn=None,
|
||||
changedBy=None,
|
||||
limit=50,
|
||||
offset=0,
|
||||
sort=None,
|
||||
search=None
|
||||
):
|
||||
"""
|
||||
Return grouped change history across all devices (global view).
|
||||
|
||||
@@ -75,6 +83,8 @@ class DevicesHistoryInstance:
|
||||
changedBy=changedBy,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sort=sort,
|
||||
search=search
|
||||
)
|
||||
|
||||
def get_available_filter_values(self, devGUID=None):
|
||||
@@ -157,7 +167,7 @@ class DevicesHistoryInstance:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _build_clauses(devGUID, changedColumn, changedBy):
|
||||
def _build_clauses(devGUID, changedColumn, changedBy, search=None):
|
||||
clauses = []
|
||||
params = []
|
||||
if devGUID:
|
||||
@@ -169,19 +179,39 @@ class DevicesHistoryInstance:
|
||||
if changedBy:
|
||||
clauses.append("changedBy = ?")
|
||||
params.append(changedBy)
|
||||
if search:
|
||||
clauses.append("""
|
||||
(
|
||||
devGUID LIKE ?
|
||||
OR changedBy LIKE ?
|
||||
OR changedColumn LIKE ?
|
||||
OR oldValue LIKE ?
|
||||
OR newValue LIKE ?
|
||||
)
|
||||
""")
|
||||
like = f"%{search}%"
|
||||
params.extend([like] * 5)
|
||||
return clauses, params
|
||||
|
||||
def _query_grouped(self, devGUID, changedColumn, changedBy, limit, offset):
|
||||
def _query_grouped(self, devGUID, changedColumn, changedBy, limit, offset, sort, search):
|
||||
"""
|
||||
Core fetch-and-group logic shared by all public query methods.
|
||||
|
||||
1. Fetch matching rows ordered by timestamp DESC.
|
||||
2. Group by (timestamp, changedBy, devGUID) in Python.
|
||||
3. Apply limit/offset at the group level.
|
||||
3. Apply sorting at group level.
|
||||
4. Apply pagination at the end.
|
||||
"""
|
||||
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy)
|
||||
|
||||
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy, search)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
|
||||
mylog("none", f"[HISTORY] SQL {where} params={params}")
|
||||
mylog("none", f"[HISTORY] sort={sort}")
|
||||
mylog("none", f"[HISTORY] sort type={type(sort)}")
|
||||
mylog("none", f"[HISTORY] search {search} ")
|
||||
mylog("none", f"[HISTORY] FINAL CALL limit={limit} offset={offset} sort={sort}")
|
||||
|
||||
rows = self._fetchall(
|
||||
f"""
|
||||
SELECT devGUID, timestamp, changedBy, changedColumn, oldValue, newValue
|
||||
@@ -192,12 +222,15 @@ class DevicesHistoryInstance:
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
# Group by (timestamp, changedBy, devGUID) — preserving DESC order
|
||||
# ---------------------------
|
||||
# 1. GROUP
|
||||
# ---------------------------
|
||||
groups = {}
|
||||
order = []
|
||||
|
||||
for row in rows:
|
||||
key = (row["timestamp"], row["changedBy"], row["devGUID"])
|
||||
|
||||
if key not in groups:
|
||||
groups[key] = {
|
||||
"devGUID": row["devGUID"],
|
||||
@@ -206,6 +239,7 @@ class DevicesHistoryInstance:
|
||||
"changes": [],
|
||||
}
|
||||
order.append(key)
|
||||
|
||||
groups[key]["changes"].append({
|
||||
"changedColumn": row["changedColumn"],
|
||||
"oldValue": row["oldValue"],
|
||||
@@ -213,4 +247,39 @@ class DevicesHistoryInstance:
|
||||
})
|
||||
|
||||
grouped_list = [groups[k] for k in order]
|
||||
return grouped_list[offset: offset + limit]
|
||||
|
||||
# ---------------------------
|
||||
# 2. SORT (group-level, stable)
|
||||
# ---------------------------
|
||||
if sort and len(sort) > 0:
|
||||
s = sort[0]
|
||||
field = s.get("field")
|
||||
direction = s.get("order", "desc")
|
||||
|
||||
reverse = direction.lower() == "desc"
|
||||
|
||||
allowed_fields = {"timestamp", "changedBy", "devGUID"}
|
||||
|
||||
if field in allowed_fields:
|
||||
|
||||
def sort_key(x):
|
||||
val = x.get(field)
|
||||
|
||||
# make timestamp safe for sorting
|
||||
if field == "timestamp":
|
||||
return val or ""
|
||||
return val
|
||||
|
||||
grouped_list = sorted(
|
||||
grouped_list,
|
||||
key=sort_key,
|
||||
reverse=reverse
|
||||
)
|
||||
|
||||
# ---------------------------
|
||||
# 3. PAGINATION (FINAL STEP ONLY)
|
||||
# ---------------------------
|
||||
start = max(offset or 0, 0)
|
||||
end = start + (limit or 50)
|
||||
|
||||
return grouped_list[start:end]
|
||||
|
||||
Reference in New Issue
Block a user