Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Systray #1967

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions api/tacticalrmm/apiv3/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path

from . import views
from systray import views as systray_views

urlpatterns = [
path("checkrunner/", views.CheckRunner.as_view()),
Expand All @@ -20,4 +21,6 @@
path("<int:pk>/chocoresult/", views.ChocoResult.as_view()),
path("<int:pk>/<str:agentid>/histresult/", views.AgentHistoryResult.as_view()),
path("<str:agentid>/config/", views.AgentConfig.as_view()),
path("<str:agentid>/systray/", views.SystrayConfig.as_view()),
path("<str:agentid>/systray/<str:file_name>/", systray_views.ServeFile.as_view()),
]
51 changes: 51 additions & 0 deletions api/tacticalrmm/apiv3/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
get_meshagent_url,
)
from logs.models import DebugLog, PendingAction
from systray.models import SysTray
from software.models import InstalledSoftware
from tacticalrmm.constants import (
AGENT_DEFER,
Expand Down Expand Up @@ -596,3 +597,53 @@ class AgentConfig(APIView):
def get(self, request, agentid):
ret = get_agent_config()
return Response(ret._to_dict())


class SystrayConfig(APIView):
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated]

def get(self, request, agentid):
try:
# Fetch the Agent instance using the provided agentid
agent = Agent.objects.filter(agent_id=agentid).first()
if not agent:
return Response({"error": "Agent not found."}, status=404)

# Access the Site instance directly from the agent
site = agent.site
if not site:
return Response(
{"error": "Site not found for the given agent."}, status=404
)

settings = get_core_settings()

systray_instance = SysTray.objects.first()
if not systray_instance:
return Response({"error": "Systray config not found"}, status=404)

if not (settings.systray_enabled or site.systray_enabled):
return Response({"error": "Support is not enabled."}, status=403)

# Here we use the `url` attribute to get the accessible URL of the icon
trayicon_icon_url = (
systray_instance.icon.url if systray_instance.icon else None
)
trayicon_name = systray_instance.name

return Response(
{
"systray_config": (
site.trayicon
if site.trayicon
else "Default logic for missing tray icon config"
),
"systray_enabled": True,
"systray_icon": trayicon_icon_url, # Return the URL instead of the file path
"systray_name": trayicon_name,
}
)
except Exception as e:
# Log the exception here for debugging
return Response({"error": "An error occurred: " + str(e)}, status=500)
18 changes: 18 additions & 0 deletions api/tacticalrmm/clients/migrations/0025_site_trayicon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2024-07-28 02:25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('clients', '0024_alter_deployment_goarch'),
]

operations = [
migrations.AddField(
model_name='site',
name='trayicon',
field=models.JSONField(blank=True, default=list),
),
]
1 change: 1 addition & 0 deletions api/tacticalrmm/clients/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class Site(BaseAuditModel):
name = models.CharField(max_length=255)
block_policy_inheritance = models.BooleanField(default=False)
failing_checks = models.JSONField(default=_default_failing_checks_data)
trayicon = models.JSONField(default=list, blank=True)
workstation_policy = models.ForeignKey(
"automation.Policy",
related_name="workstation_sites",
Expand Down
1 change: 1 addition & 0 deletions api/tacticalrmm/clients/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class Meta:
"block_policy_inheritance",
"maintenance_mode",
"failing_checks",
"trayicon",
)

def validate(self, val):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2024-07-28 02:25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0047_alter_coresettings_notify_on_warning_alerts'),
]

operations = [
migrations.AddField(
model_name='coresettings',
name='systray_enabled',
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions api/tacticalrmm/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class CoreSettings(BaseAuditModel):
enable_server_webterminal = models.BooleanField(default=False)
notify_on_info_alerts = models.BooleanField(default=False)
notify_on_warning_alerts = models.BooleanField(default=True)
systray_enabled = models.BooleanField(default=False)

def save(self, *args, **kwargs) -> None:
from alerts.tasks import cache_agents_alert_template
Expand Down
2 changes: 2 additions & 0 deletions api/tacticalrmm/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
path("status/", views.status),
path("openai/generate/", views.OpenAICodeCompletion.as_view()),
path("webtermperms/", views.webterm_perms),
path("systray/", views.SysTrayView.as_view()),
path("systray/<str:file_name>/", views.ServeFile.as_view()),
]


Expand Down
72 changes: 71 additions & 1 deletion api/tacticalrmm/core/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import json
import os
from contextlib import suppress
from pathlib import Path

import psutil
import requests
from cryptography import x509
from django.conf import settings
from django.http import JsonResponse
from django.http import JsonResponse, FileResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone as djangotime
from django.views.decorators.csrf import csrf_exempt
Expand All @@ -30,6 +31,8 @@
token_is_valid,
)
from logs.models import AuditLog
from systray.models import SysTray
from systray.serializers import SysTraySerializer
from tacticalrmm.constants import AuditActionType, PAStatus
from tacticalrmm.helpers import get_certs, notify_error
from tacticalrmm.permissions import (
Expand Down Expand Up @@ -658,3 +661,70 @@ def post(self, request: Request) -> Response:
)

return Response(response_data["choices"][0]["message"]["content"])


class SysTrayView(APIView):
permission_classes = [IsAuthenticated]

def get(self, request):
systray = SysTray.objects.all()
serializer = SysTraySerializer(systray, many=True)
return Response(serializer.data)

def post(self, request):
serializer = SysTraySerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=drf_status.HTTP_200_OK)
return Response(serializer.errors, status=drf_status.HTTP_400_BAD_REQUEST)

# temp validation hack to update name in ui
def should_ignore_icon_errors(self, errors):
return "icon" in errors and len(errors) == 1

def put(self, request):
systray_id = request.data.get("id")
if not systray_id:
return Response(
{"error": "ID is required for updating a SysTray instance."},
status=drf_status.HTTP_400_BAD_REQUEST,
)

systray = get_object_or_404(SysTray, pk=systray_id)
serializer = SysTraySerializer(systray, data=request.data, partial=True)

# Attempt to validate the serializer with the provided data
if serializer.is_valid():
# Save the validated changes
serializer.save()
return Response(serializer.data, status=drf_status.HTTP_200_OK)
else:
# Check if the only error is related to the 'icon' field
errors = serializer.errors
if "icon" in errors and len(errors) == 1:
# Manually update and save the 'name' if it's provided in the request and ignore the 'icon' error
if "name" in request.data:
systray.name = request.data["name"]
systray.save(update_fields=["name"])
return Response(
{"message": "Name updated successfully, icon ignored."},
status=drf_status.HTTP_200_OK,
)
# Return other validation errors if present
return Response(errors, status=drf_status.HTTP_400_BAD_REQUEST)


class ServeFile(APIView):
permission_classes = [IsAuthenticated]

def get(self, request, file_name):
file_path = os.path.join(
"/rmm/api/tacticalrmm/tacticalrmm/private/assets/", file_name
)

if not os.path.exists(file_path):
return Response(
{"error": "File does not exist"}, status=drf_status.HTTP_400_BAD_REQUEST
)

return FileResponse(open(file_path, "rb"))
Empty file.
4 changes: 4 additions & 0 deletions api/tacticalrmm/systray/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from django.contrib import admin
from .models import SysTray

admin.site.register(SysTray)
6 changes: 6 additions & 0 deletions api/tacticalrmm/systray/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class SystrayConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "systray"
24 changes: 24 additions & 0 deletions api/tacticalrmm/systray/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.14 on 2024-07-28 02:25

from django.db import migrations, models
import systray.models
import systray.utils


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='SysTray',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=200, null=True)),
('icon', models.FileField(blank=True, null=True, storage=systray.utils.get_systray_assets_fs, unique=True, upload_to='', validators=[systray.models.validate_ico_file])),
],
),
]
Empty file.
24 changes: 24 additions & 0 deletions api/tacticalrmm/systray/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db import models
from .utils import get_systray_assets_fs
from django.core.exceptions import ValidationError
import os


def validate_ico_file(value):
ext = os.path.splitext(value.name)[1]
if not ext.lower() == ".ico":
raise ValidationError("Only .ico files are allowed.")


class SysTray(models.Model):
name = models.CharField(max_length=200, blank=True, null=True)
icon = models.FileField(
storage=get_systray_assets_fs,
blank=True,
null=True,
unique=True,
validators=[validate_ico_file],
)

def __str__(self) -> str:
return f"{self.name} = {self.icon}"
8 changes: 8 additions & 0 deletions api/tacticalrmm/systray/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import SysTray


class SysTraySerializer(serializers.ModelSerializer):
class Meta:
model = SysTray
fields = "__all__"
25 changes: 25 additions & 0 deletions api/tacticalrmm/systray/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.conf import settings as djangosettings


class Settings:
def __init__(self) -> None:
self.settings = djangosettings

@property
def SYSTRAY_ASSETS_BASE_PATH(self) -> str:
return getattr(
self.settings,
"SYSTRAY_ASSETS_BASE_PATH",
"/rmm/api/tacticalrmm/tacticalrmm/private/assets/",
)

@property
def SYSTRAY_BASE_URL(self) -> str:
return getattr(
self.settings,
"SYSTRAY_BASE_URL",
f"https://{djangosettings.ALLOWED_HOSTS[0]}",
)


settings = Settings()
13 changes: 13 additions & 0 deletions api/tacticalrmm/systray/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from tacticalrmm.celery import app
import asyncio
from agents.models import Agent
import time


@app.task
def update_systray_config():
agents = Agent.objects.all()
for agent in agents:
cmd = {"func": "systrayconfig"}
asyncio.run(agent.nats_cmd(cmd, wait=False))
time.sleep(1)
25 changes: 25 additions & 0 deletions api/tacticalrmm/systray/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import os

from django.core.files.storage import FileSystemStorage

from .settings import settings


class SysTrayAssetStorage(FileSystemStorage):

def isfile(self, *, path: str) -> bool:
return os.path.isfile(self.path(name=path))

def isico(self, *, path: str) -> bool:
filepath = self.path(name=path)
return os.path.isfile(filepath) and filepath.lower().endswith(".ico")


systray_assets_fs = SysTrayAssetStorage(
location=settings.SYSTRAY_ASSETS_BASE_PATH,
base_url=f"{settings.SYSTRAY_BASE_URL}/core/systray/",
)


def get_systray_assets_fs():
return systray_assets_fs
Loading
Loading