settings: add "tasks" button (advanced)

added a GET /tasks route to list tasks with a description (untranslated,
but this is mostly a dev feature anyway). Loaded in a modal by enabling
advanced settings and pressing the Tasks button at the top (where logs,
backups, restart are). Also added some icons in settings, and removed
some redundant "flex flex-row"s on buttons and reduced the spacing in
those with icons to gap-1.
This commit is contained in:
Harvey Tindall
2025-11-29 15:43:06 +00:00
parent 598a389e3d
commit fb1b673dee
8 changed files with 138 additions and 16 deletions

View File

@@ -69,11 +69,18 @@
</div>
</div>
<div id="modal-logs" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content content card">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.logs }}<span class="modal-close">&times;</span></span>
<pre class="monospace" id="log-area"></pre>
</div>
</div>
<div id="modal-tasks" class="modal">
<div class="relative mx-auto my-[10%] w-min card flex flex-col gap-2">
<h1 class="heading">{{ .strings.tasks }}<span class="modal-close">&times;</span></h1>
<p class="content">{{ .strings.tasksDescription }}</p>
<div id="modal-tasks-list" class="flex flex-col gap-2"></div>
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
@@ -737,7 +744,7 @@
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
@@ -831,8 +838,9 @@
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
<button class="button ~neutral @low accounts-search-clear gap-1">
<i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button>
</div>
</div>
@@ -840,7 +848,7 @@
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
@@ -863,7 +871,7 @@
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
@@ -890,8 +898,9 @@
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
<button class="button ~neutral @low activity-search-clear gap-1">
<i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
@@ -902,7 +911,7 @@
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low activity-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<button class="button ~info @low center activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
@@ -920,10 +929,11 @@
</label>
</div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low gap-1 unfocused" id="settings-tasks"><i class="ri-calendar-schedule-line"></i>{{ .strings.tasks }}</span>
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
<span class="button ~info @low gap-1" id="settings-backups"><i class="icon ri-file-copy-line"></i>{{ .strings.backups }}</span>
<span class="button ~neutral @low gap-1" id="settings-restart"><i class="icon ri-restart-line"></i>{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused gap-1" id="settings-save"><i class="icon ri-save-line"></i>{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">

View File

@@ -46,6 +46,8 @@
"userLabel": "User Label",
"userLabelDescription": "Label to apply to users created with this invite.",
"logs": "Logs",
"tasks": "Tasks",
"tasksDescription": "Tasks are large actions that may be run periodically in the background. You can manually trigger them here if you wish.",
"announce": "Announce",
"templates": "Templates",
"subject": "Subject",
@@ -58,6 +60,7 @@
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"run": "Run",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
@@ -264,7 +267,8 @@
"errorInvalidDate": "Date is invalid.",
"errorInvalidJSON": "Invalid JSON.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available."
"noUpdatesAvailable": "No new updates available.",
"runTask": "Triggered task."
},
"quantityStrings": {
"modifySettingsFor": {

View File

@@ -492,3 +492,13 @@ type PagePathsDTO struct {
// The subdirectory the app is meant to be accessed from ("Reverse proxy subfolder")
TrueBase string `json:"TrueBase"`
}
type TasksDTO struct {
Tasks []TaskDTO `json:"tasks"`
}
type TaskDTO struct {
URL string `json:"url"`
Name string `json:"name"`
Description string ` json:"description"`
}

View File

@@ -244,6 +244,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
api.GET(p+"/logs", app.GetLog)
api.GET(p+"/tasks", app.TaskList)
api.POST(p+"/tasks/housekeeping", app.TaskHousekeeping)
api.POST(p+"/tasks/users", app.TaskUserCleanup)
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {

View File

@@ -7,6 +7,35 @@ import (
"github.com/gin-gonic/gin"
)
// @Summary List existing task routes, with friendly names and descriptions.
// @Produce json
// @Success 200 {object} TasksDTO
// @Router /tasks [get]
// @Security Bearer
// @tags Tasks
func (app *appContext) TaskList(gc *gin.Context) {
resp := TasksDTO{Tasks: []TaskDTO{
TaskDTO{
URL: "/tasks/housekeeping",
Name: "Housekeeping",
Description: "General housekeeping tasks: Clearing expired invites & activities, unused contact details & captchas, etc.",
},
TaskDTO{
URL: "/tasks/users",
Name: "Users",
Description: "Checks for (pending) account expiries and performs the appropriate actions.",
},
}}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
resp.Tasks = append(resp.Tasks, TaskDTO{
URL: "/tasks/jellyseerr",
Name: "Jellyseerr user import",
Description: "Imports existing users into jellyfin and synchronizes contact details. Should only need to be run once when the feature is enabled, which is done automatically.",
})
}
gc.JSON(200, resp)
}
// @Summary Triggers general housekeeping tasks: Clearing expired invites, activities, unused contact details, captchas, etc.
// @Success 204
// @Router /tasks/housekeeping [post]

View File

@@ -75,6 +75,8 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.logs = new Modal(document.getElementById("modal-logs"));
window.modals.tasks = new Modal(document.getElementById("modal-tasks"));
window.modals.backedUp = new Modal(document.getElementById("modal-backed-up"));
window.modals.backups = new Modal(document.getElementById("modal-backups"));

View File

@@ -936,14 +936,17 @@ export class settingsList {
private _settings: Settings;
private _advanced: boolean = false;
private _searchbox: HTMLInputElement = document.getElementById("settings-search") as HTMLInputElement;
private _clearSearchboxButtons: Array<HTMLButtonElement> = Array.from(document.getElementsByClassName("settings-search-clear")) as Array<HTMLButtonElement>;
private _searchbox = document.getElementById("settings-search") as HTMLInputElement;
private _clearSearchboxButtons = Array.from(document.getElementsByClassName("settings-search-clear")) as Array<HTMLButtonElement>;
private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found");
private _backupSortDirection = document.getElementById("settings-backups-sort-direction") as HTMLButtonElement;
private _backupSortAscending = true;
private _tasksList: TasksList;
private _tasksButton = document.getElementById("settings-tasks") as HTMLButtonElement;
// Must be called -after- all section have been added.
// Takes all groups at once since members might contain each other.
addGroups = (groups: Group[]) => {
@@ -1160,6 +1163,9 @@ export class settingsList {
this._backup();
};
this._tasksList = new TasksList();
this._tasksButton.onclick = this._tasksList.load;
document.addEventListener("settings-show-panel", (event: CustomEvent) => {
this._showPanel(event.detail as string);
});
@@ -1187,17 +1193,21 @@ export class settingsList {
advancedEnableToggle.onchange = () => {
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
};
document.addEventListener("settings-advancedState", () => {
const parent = advancedEnableToggle.parentElement;
this._advanced = advancedEnableToggle.checked;
if (this._advanced) {
parent.classList.add("~urge");
parent.classList.remove("~neutral");
this._tasksButton.classList.remove("unfocused");
} else {
parent.classList.add("~neutral");
parent.classList.remove("~urge");
this._tasksButton.classList.add("unfocused");
}
this._searchbox.oninput(null);
};
});
advancedEnableToggle.checked = false;
this._searchbox.oninput = () => {
@@ -1746,3 +1756,58 @@ class MessageEditor {
};
}
}
class TasksList {
private _list: HTMLElement = document.getElementById("modal-tasks-list");
load = () => _get("/tasks", null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) return;
let resp = req.response["tasks"] as TaskDTO[];
this._list.textContent = "";
for (let t of resp) {
const task = new Task(t);
this._list.appendChild(task.asElement());
}
window.modals.tasks.show();
});
}
interface TaskDTO {
url: string;
name: string;
description: string;
}
class Task {
private _el: HTMLElement;
asElement = () => { return this._el };
constructor(t: TaskDTO) {
this._el = document.createElement("div");
this._el.classList.add("aside", "flex", "flex-row", "gap-4", "justify-between", "dark:shadow-md")
this._el.innerHTML = `
<div class="flex flex-col gap-1">
<div class="flex flex-row gap-2 items-baseline w-max">
<h2 class="heading text-2xl">${t.name}</h2>
<span class="text-sm font-mono">${t.url}</span>
</div>
<p class="max-w-[40ch] wrap-break-word text-justify">${t.description}</p>
</div>
<button class="button ~urge @low p-6">${window.lang.strings("run")}</button>
`;
const button = this._el.querySelector("button") as HTMLButtonElement;
button.onclick = () => {
addLoader(button);
_post(t.url, null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
removeLoader(button);
setTimeout(window.modals.tasks.close, 1000)
if (req.status != 204) {
window.notifications.customError("errorRunTask", window.lang.notif("errorFailureCheckLogs"));
return;
}
window.notifications.customSuccess("runTask", window.lang.notif("runTask"));
})
}
}
}

View File

@@ -123,6 +123,7 @@ declare interface Modals {
sendPWR?: Modal;
pwr?: Modal;
logs: Modal;
tasks: Modal;
email?: Modal;
enableReferralsUser?: Modal;
enableReferralsProfile?: Modal;