Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f349cf9
fix: button misalignment on long lines (#117)
ajasnosz Jul 20, 2026
bd749db
chore(release): 1.2.3-beta.1
srv-rr-github-token Jul 20, 2026
be7ac41
fix: stale task handling (#122)
ajasnosz Jul 21, 2026
5535883
chore(release): 1.2.3-beta.2
srv-rr-github-token Jul 21, 2026
4a7c00d
chore(deps): update dependency uuid to v14.0.1 (#121)
renovate[bot] Jul 21, 2026
5433cf4
chore(deps): update dependency tar to v7.5.20 (#120)
renovate[bot] Jul 21, 2026
e5aa2c9
chore(deps): update dependency qs to v6.15.3 (#118)
renovate[bot] Jul 21, 2026
b69acf8
chore(deps): update dependency flask-cors to v6.0.5 (#116)
renovate[bot] Jul 21, 2026
a3d26ce
chore(deps): update dependency style-loader to v3.3.4 (#119)
renovate[bot] Jul 21, 2026
41e835a
fix: update ci actions (#123)
ajasnosz Jul 22, 2026
ce3d5de
chore(release): 1.2.3-beta.3
srv-rr-github-token Jul 22, 2026
e36bb0f
chore(deps): update dependency @babel/core to v7.29.6 [security] (#109)
renovate[bot] Jul 22, 2026
2f78ab5
chore(deps): update dependency webpack-dev-server to v5.2.5 [security…
renovate[bot] Jul 22, 2026
2d349bd
chore(deps): update dependency cors to v2.8.6 (#113)
renovate[bot] Jul 22, 2026
b61d23e
chore(deps): update dependency eslint-plugin-react-hooks to v4.6.2 (#…
renovate[bot] Jul 22, 2026
5ccd58b
chore(deps): update dependency eslint-import-resolver-webpack to v0.1…
renovate[bot] Jul 22, 2026
3fa5c20
fix: change font type to fast load (#145)
ajasnosz Jul 30, 2026
072b544
chore(release): 1.2.3-beta.4
srv-rr-github-token Jul 30, 2026
afabf12
fix: load config from files (#144)
ajasnosz Jul 30, 2026
e748406
chore(release): 1.2.3-beta.5
srv-rr-github-token Jul 30, 2026
9680db0
fix: optional ports for groups (#147)
ajasnosz Aug 13, 2026
4decdf3
chore(release): 1.2.3-beta.6
srv-rr-github-token Aug 13, 2026
89f68a1
fix: add pagination for groups (#148)
ajasnosz Aug 17, 2026
da1a6d5
chore(release): 1.2.3-beta.7
srv-rr-github-token Aug 17, 2026
38ccdb2
fix: add in bulk device groups (#149)
ajasnosz Aug 19, 2026
1934658
fix: vuln bump (#150)
ajasnosz Aug 20, 2026
885668a
fix: bump security updates (#151)
ajasnosz Aug 20, 2026
9ff78e4
chore(release): 1.2.3-beta.8
srv-rr-github-token Aug 20, 2026
60d625f
fix: packages bump (#155)
ajasnosz Aug 21, 2026
037bb4f
chore(deps): update dependency fast-uri to v4.1.3
renovate[bot] Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/workflows/ci-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,18 @@ jobs:

#Build docker images
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0
uses: docker/setup-buildx-action@v4.2.0
- name: Login to GitHub Packages Docker Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - frontend
id: docker_meta
uses: docker/metadata-action@v5.5.1
uses: docker/metadata-action@v6.2.0
with:
images: ghcr.io/splunk/sc4snmp-ui/frontend/container
tags: |
Expand All @@ -68,7 +68,7 @@ jobs:
type=ref,event=branch
- name: Build and push action - frontend
id: docker_action_build_frontend
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7.3.0
with:
context: frontend
push: false
Expand All @@ -94,18 +94,18 @@ jobs:

#Build docker images
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0
uses: docker/setup-buildx-action@v4.2.0
- name: Login to GitHub Packages Docker Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - backend
id: docker_meta
uses: docker/metadata-action@v5.5.1
uses: docker/metadata-action@v6.2.0
with:
images: ghcr.io/splunk/sc4snmp-ui/backend/container
tags: |
Expand All @@ -118,7 +118,7 @@ jobs:
type=ref,event=branch
- name: Build and push action - backend
id: docker_action_build_backend
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7.3.0
with:
context: backend
push: false
Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/ci-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,18 @@ jobs:

#Build docker images
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0
uses: docker/setup-buildx-action@v4.2.0
- name: Login to GitHub Packages Docker Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - frontend
id: docker_meta
uses: docker/metadata-action@v5.5.1
uses: docker/metadata-action@v6.2.0
with:
images: ghcr.io/splunk/sc4snmp-ui/frontend/container
tags: |
Expand All @@ -64,7 +64,7 @@ jobs:
type=ref,event=pr
- name: Build and push action - frontend
id: docker_action_build_frontend
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7.3.0
with:
context: frontend
push: true
Expand All @@ -90,18 +90,18 @@ jobs:

#Build docker images
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0
uses: docker/setup-buildx-action@v4.2.0
- name: Login to GitHub Packages Docker Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - backend
id: docker_meta
uses: docker/metadata-action@v5.5.1
uses: docker/metadata-action@v6.2.0
with:
images: ghcr.io/splunk/sc4snmp-ui/backend/container
tags: |
Expand All @@ -115,7 +115,7 @@ jobs:
type=ref,event=pr
- name: Build and push action - backend
id: docker_action_build_backend
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7.3.0
with:
context: backend
push: true
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Changelog

### Changed
- fix button misalignment on multilines in table
- fix schedules not resuming for unchanged inventory records after Redis/RedBeat is reset while MongoDB data is preserved
- fix font flash on page switch by inlining Proxima Nova as WOFF2 instead of loading OTF files on demand
- add "Restore configuration" button to recover Profiles, Groups, and Inventory from section files on disk when MongoDB data is lost
- hide Logout button when authentication is disabled
- fix port being required when adding a Group inventory record
- fix Groups tab freezing for large device counts by paginating the group list and batching inventory-membership lookups
- add "Bulk add devices" to a group, supporting a manual grid or a pasted address list with shared SNMP config

## [1.2.1]

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ pip3 install -r requirements.txt
Run mongoDB in docker:

```shell
docker run --rm -d -p 27017:27017 --name example-mongo mongo:4.4.6
docker run --rm -d -p 27017:27017 --name example-mongo mongo:8.3.4
```

To start backend service run:
Expand Down
5 changes: 2 additions & 3 deletions backend/SC4SNMP_UI_backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

load_dotenv()

__version__ = "1.2.2"
__version__ = "1.2.3-beta.8"

MONGO_URI = os.getenv("MONGO_URI")
log = logging.getLogger('gunicorn.error')
Expand Down Expand Up @@ -88,7 +88,7 @@ class AuthNotConfiguredException(Exception):
pass


limiter = Limiter(key_func=get_remote_address, default_limits=[])
limiter = Limiter(key_func=get_remote_address, default_limits=[], storage_uri=REDBEAT_URL)


def create_app():
Expand Down Expand Up @@ -135,7 +135,6 @@ def create_app():
CORS(app, origins=cors_origins, supports_credentials=True)

limiter.init_app(app)
limiter.storage_uri = REDBEAT_URL

if REDIS_MODE == "replication":
broker_transport_options = {
Expand Down
39 changes: 37 additions & 2 deletions backend/SC4SNMP_UI_backend/apply_changes/handling_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,48 @@ def handle(self, request: dict):
ScheduleHandler schedules the kubernetes job with updated sc4snmp configuration
"""
record = list(mongo_config_collection.find())[0]
if not record["currently_scheduled"] and request["schedule_new_job"]:
currently_scheduled = record["currently_scheduled"]
if currently_scheduled and not self._is_task_still_scheduled(record.get("task_id")):
current_app.logger.info(
f"ScheduleHandler: currently_scheduled was True but task_id "
f"{record.get('task_id')} is no longer known to any worker; resetting stale state."
)
mongo_config_collection.update_one({"_id": record["_id"]},
{"$set": {"currently_scheduled": False, "task_id": None}})
currently_scheduled = False

if not currently_scheduled and request["schedule_new_job"]:
# If the task isn't currently scheduled, schedule it and update its state in mongo.
async_result = run_job.apply_async(countdown=request["job_delay"], queue='apply_changes')
mongo_config_collection.update_one({"_id": record["_id"]},
{"$set": {"currently_scheduled": True, "task_id": async_result.id}})
current_app.logger.info(
f"ScheduleHandler: scheduling new task with the delay of {request['job_delay']} seconds.")
currently_scheduled = True
else:
current_app.logger.info("ScheduleHandler: new job wasn't scheduled.")
return request["job_delay"], record["currently_scheduled"]
return request["job_delay"], currently_scheduled

@staticmethod
def _is_task_still_scheduled(task_id):
"""
Check whether task_id is still known to any live Celery worker as a scheduled (ETA)
task. Returns False (treat as stale) if there's no task_id, or if no worker reports
it - either because it genuinely doesn't exist anymore, or because inspection itself
failed (e.g. broker unreachable), which is the same "can't confirm it's alive" case.
"""
if not task_id:
return False
try:
celery_app = current_app.extensions["celery"]
scheduled = celery_app.control.inspect().scheduled() or {}
except Exception as e:
current_app.logger.warning(
f"ScheduleHandler: failed to inspect scheduled tasks, treating task_id "
f"{task_id} as stale: {e}")
return False
return any(
entry.get("request", {}).get("id") == task_id
for worker_tasks in scheduled.values()
for entry in worker_tasks
)
133 changes: 132 additions & 1 deletion backend/SC4SNMP_UI_backend/apply_changes/routes.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,107 @@
from flask import Blueprint, jsonify, current_app
from SC4SNMP_UI_backend.auth.utils import login_required
from SC4SNMP_UI_backend.apply_changes.apply_changes import ApplyChanges
from SC4SNMP_UI_backend.apply_changes.handling_chain import EmptyValuesFileException, YamlParserException
from SC4SNMP_UI_backend.apply_changes import handling_chain
from SC4SNMP_UI_backend.apply_changes.handling_chain import (
EmptyValuesFileException,
YamlParserException,
mongo_groups,
mongo_inventory,
mongo_profiles,
)
from SC4SNMP_UI_backend.common.file_to_config_utils import (
groups_yaml_to_documents,
profiles_yaml_to_documents,
inventory_csv_to_documents,
)
from SC4SNMP_UI_backend.common.mongo_utils import run_write
import os
import traceback
import yaml

apply_changes_blueprint = Blueprint('common_blueprint', __name__)
JOB_CREATION_RETRIES = int(os.getenv("JOB_CREATION_RETRIES", 10))

# Maps a section name to its key in values.yaml, used to derive the section
# file name the same way SaveConfigToFileHandler does (TMP_FILE_PREFIX +
# key.replace(".", "_") + ".yaml").
SECTION_FILE_KEYS = {
"groups": "scheduler.groups",
"profiles": "scheduler.profiles",
"inventory": "poller.inventory",
}


def _section_file_path(section_key):
file_name = handling_chain.TMP_FILE_PREFIX + SECTION_FILE_KEYS[section_key].replace(".", "_") + ".yaml"
return os.path.join(handling_chain.VALUES_DIRECTORY, file_name)


def _load_yaml_section(section_key):
"""
Reads and parses a section file with plain PyYAML (not ruamel), since the
result is inserted straight into Mongo and must not carry ruamel's
CommentedMap/scalar-string wrappers. Returns None if the file isn't
present, so the caller can skip restoring that section.
"""
file_path = _section_file_path(section_key)
if not os.path.exists(file_path):
return None
with open(file_path, "r") as file:
return yaml.safe_load(file)


def _reconcile_inventory(inventory_documents, session=None):
"""
Orphan-safe replace of inventory_ui: hosts/groups no longer present in the
restored section file are marked delete=True (mirroring the soft-delete
inventory/routes.py itself performs) so the connector loader tears down
their RedBeat walk schedules on the next Apply Changes, rather than the
rows just vanishing from Mongo with their schedules left orphaned in
Redis. Rows still present in the file are upserted by {address, port},
clearing any prior delete=True on rows that reappear.
"""
parsed_keys = {(doc["address"], doc["port"]) for doc in inventory_documents}
existing_records = list(mongo_inventory.find({"delete": False}, session=session))
for record in existing_records:
if (record["address"], record["port"]) not in parsed_keys:
mongo_inventory.update_one(
{"_id": record["_id"]}, {"$set": {"delete": True}}, session=session
)

for doc in inventory_documents:
mongo_inventory.update_one(
{"address": doc["address"], "port": doc["port"]},
{"$set": doc},
upsert=True,
session=session,
)


def _restore_documents(session, groups_yaml, profiles_yaml, inventory_yaml):
"""
Performs all Mongo writes for a restore, forwarding session (possibly
None) to every write so common.mongo_utils.run_write can commit them
atomically when the deployment supports transactions.
"""
if groups_yaml is not None:
group_documents = groups_yaml_to_documents(groups_yaml)
mongo_groups.delete_many({}, session=session)
if group_documents:
mongo_groups.insert_many(group_documents, session=session)

if profiles_yaml is not None:
profile_documents = profiles_yaml_to_documents(profiles_yaml)
mongo_profiles.delete_many({}, session=session)
if profile_documents:
mongo_profiles.insert_many(profile_documents, session=session)

if inventory_yaml is not None:
inventory_csv = (inventory_yaml or {}).get("inventory", "")
inventory_documents = inventory_csv_to_documents(inventory_csv)
_reconcile_inventory(inventory_documents, session=session)


@apply_changes_blueprint.route("/apply-changes", methods=['POST'])
@login_required
def apply_changes():
Expand All @@ -23,6 +117,43 @@ def apply_changes():
result = jsonify({"message": message})
return result, 200


@apply_changes_blueprint.route("/load-config", methods=['POST'])
@login_required
def load_config():
"""
Restores profiles, groups, and inventory from the on-disk section files
(sc4snmp_ui_scheduler_groups.yaml / sc4snmp_ui_scheduler_profiles.yaml /
sc4snmp_ui_poller_inventory.yaml) into their *_ui Mongo collections, then
triggers the same Apply Changes flow as /apply-changes so the connector's
inventory Job picks the restored configuration up (both UI visibility and
polling). This is the only inverse (file -> Mongo) path in the backend;
everywhere else Mongo is the sole source of truth.
"""
groups_yaml = _load_yaml_section("groups")
profiles_yaml = _load_yaml_section("profiles")
inventory_yaml = _load_yaml_section("inventory")

if groups_yaml is None and profiles_yaml is None and inventory_yaml is None:
result = jsonify({"message": "No section files found in the values directory to restore from."})
return result, 400

run_write(lambda session: _restore_documents(session, groups_yaml, profiles_yaml, inventory_yaml))

changes = ApplyChanges()
job_delay, currently_scheduled = changes.apply_changes()
if job_delay <= 1 and currently_scheduled:
message = "Configuration was restored from section files. There might be previous kubernetes job still " \
"present in the namespace. Configuration update will be " \
f"retried {JOB_CREATION_RETRIES} times. If your configuration won't be updated in a few minutes, make sure that " \
f"snmp-splunk-connect-for-snmp-inventory job isn't present in your kubernetes deployment namespace and " \
f"click 'Apply changes' button once again."
else:
message = f"Configuration was restored from section files. It will be updated in approximately {job_delay} seconds."
result = jsonify({"message": message})
return result, 200


@apply_changes_blueprint.errorhandler(Exception)
def handle_exception(e):
current_app.logger.error(traceback.format_exc())
Expand Down
2 changes: 1 addition & 1 deletion backend/SC4SNMP_UI_backend/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ def logout():
def status():
from flask import g

return jsonify({"username": g.current_user})
return jsonify({"username": g.current_user, "authEnabled": AUTH_ENABLED})
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ def ui2backend(self, document: dict, **kwargs):
profiles += ";"
result = {
'address': document['address'],
'port': int(document['port']),
'port': int(document['port']) if str(document.get('port', "")).strip() else 161,
'version': document['version'],
'community': document['community'],
'secret': document['secret'],
Expand Down
Loading
Loading