mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-04-15 20:07:30 +02:00
Enable to run taggers from within admin web interface
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/internal/tagger"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/util"
|
"github.com/ClusterCockpit/cc-lib/v2/util"
|
||||||
@@ -152,6 +153,8 @@ func (api *RestAPI) MountConfigAPIRoutes(r chi.Router) {
|
|||||||
r.Delete("/config/users/", api.deleteUser)
|
r.Delete("/config/users/", api.deleteUser)
|
||||||
r.Post("/config/user/{id}", api.updateUser)
|
r.Post("/config/user/{id}", api.updateUser)
|
||||||
r.Post("/config/notice/", api.editNotice)
|
r.Post("/config/notice/", api.editNotice)
|
||||||
|
r.Get("/config/taggers/", api.getTaggers)
|
||||||
|
r.Post("/config/taggers/run/", api.runTagger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +271,42 @@ func (api *RestAPI) editNotice(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *RestAPI) getTaggers(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||||
|
handleError(fmt.Errorf("only admins are allowed to list taggers"), http.StatusForbidden, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(rw).Encode(tagger.ListTaggers()); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode tagger list: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *RestAPI) runTagger(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||||
|
handleError(fmt.Errorf("only admins are allowed to run taggers"), http.StatusForbidden, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
handleError(fmt.Errorf("missing required parameter: name"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tagger.RunTaggerByName(name); err != nil {
|
||||||
|
handleError(err, http.StatusConflict, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := rw.Write([]byte(fmt.Sprintf("Tagger %s started", name))); err != nil {
|
||||||
|
cclog.Errorf("Failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getJWT godoc
|
// getJWT godoc
|
||||||
// @summary Generate JWT token
|
// @summary Generate JWT token
|
||||||
// @tags Frontend
|
// @tags Frontend
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
package tagger
|
package tagger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
@@ -29,11 +30,31 @@ type Tagger interface {
|
|||||||
Match(job *schema.Job)
|
Match(job *schema.Job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaggerInfo holds metadata about a tagger for JSON serialization.
|
||||||
|
type TaggerInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
initOnce sync.Once
|
initOnce sync.Once
|
||||||
jobTagger *JobTagger
|
jobTagger *JobTagger
|
||||||
|
statusMu sync.Mutex
|
||||||
|
taggerStatus = map[string]bool{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Known tagger definitions: name -> (type, factory)
|
||||||
|
type taggerDef struct {
|
||||||
|
ttype string
|
||||||
|
factory func() Tagger
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownTaggers = map[string]taggerDef{
|
||||||
|
"AppTagger": {ttype: "start", factory: func() Tagger { return &AppTagger{} }},
|
||||||
|
"JobClassTagger": {ttype: "stop", factory: func() Tagger { return &JobClassTagger{} }},
|
||||||
|
}
|
||||||
|
|
||||||
// JobTagger coordinates multiple taggers that run at different job lifecycle events.
|
// JobTagger coordinates multiple taggers that run at different job lifecycle events.
|
||||||
// It maintains separate lists of taggers that run when jobs start and when they stop.
|
// It maintains separate lists of taggers that run when jobs start and when they stop.
|
||||||
type JobTagger struct {
|
type JobTagger struct {
|
||||||
@@ -88,6 +109,73 @@ func (jt *JobTagger) JobStopCallback(job *schema.Job) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTaggers returns information about all known taggers with their current running status.
|
||||||
|
func ListTaggers() []TaggerInfo {
|
||||||
|
statusMu.Lock()
|
||||||
|
defer statusMu.Unlock()
|
||||||
|
|
||||||
|
result := make([]TaggerInfo, 0, len(knownTaggers))
|
||||||
|
for name, def := range knownTaggers {
|
||||||
|
result = append(result, TaggerInfo{
|
||||||
|
Name: name,
|
||||||
|
Type: def.ttype,
|
||||||
|
Running: taggerStatus[name],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunTaggerByName starts a tagger by name asynchronously on all jobs.
|
||||||
|
// Returns an error if the name is unknown or the tagger is already running.
|
||||||
|
func RunTaggerByName(name string) error {
|
||||||
|
def, ok := knownTaggers[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown tagger: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMu.Lock()
|
||||||
|
if taggerStatus[name] {
|
||||||
|
statusMu.Unlock()
|
||||||
|
return fmt.Errorf("tagger %s is already running", name)
|
||||||
|
}
|
||||||
|
taggerStatus[name] = true
|
||||||
|
statusMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
statusMu.Lock()
|
||||||
|
taggerStatus[name] = false
|
||||||
|
statusMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
t := def.factory()
|
||||||
|
if err := t.Register(); err != nil {
|
||||||
|
cclog.Errorf("Failed to register tagger %s: %s", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r := repository.GetJobRepository()
|
||||||
|
jl, err := r.GetJobList(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("Error getting job list for tagger %s: %s", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("Running tagger %s on %d jobs", name, len(jl))
|
||||||
|
for _, id := range jl {
|
||||||
|
job, err := r.FindByIDDirect(id)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("Error getting job %d for tagger %s: %s", id, name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Match(job)
|
||||||
|
}
|
||||||
|
cclog.Infof("Tagger %s completed", name)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RunTaggers applies all configured taggers to all existing jobs in the repository.
|
// RunTaggers applies all configured taggers to all existing jobs in the repository.
|
||||||
// This is useful for retroactively applying tags to jobs that were created before
|
// This is useful for retroactively applying tags to jobs that were created before
|
||||||
// the tagger system was initialized or when new tagging rules are added.
|
// the tagger system was initialized or when new tagging rules are added.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import ShowUsers from "./admin/ShowUsers.svelte";
|
import ShowUsers from "./admin/ShowUsers.svelte";
|
||||||
import Options from "./admin/Options.svelte";
|
import Options from "./admin/Options.svelte";
|
||||||
import NoticeEdit from "./admin/NoticeEdit.svelte";
|
import NoticeEdit from "./admin/NoticeEdit.svelte";
|
||||||
|
import RunTaggers from "./admin/RunTaggers.svelte";
|
||||||
|
|
||||||
/* Svelte 5 Props */
|
/* Svelte 5 Props */
|
||||||
let {
|
let {
|
||||||
@@ -70,4 +71,5 @@
|
|||||||
</Col>
|
</Col>
|
||||||
<Options config={ccconfig} {clusterNames}/>
|
<Options config={ccconfig} {clusterNames}/>
|
||||||
<NoticeEdit {ncontent}/>
|
<NoticeEdit {ncontent}/>
|
||||||
|
<RunTaggers />
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
142
web/frontend/src/config/admin/RunTaggers.svelte
Normal file
142
web/frontend/src/config/admin/RunTaggers.svelte
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<!--
|
||||||
|
@component Admin card for running individual job taggers on all jobs
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Col,
|
||||||
|
Card,
|
||||||
|
CardTitle,
|
||||||
|
CardBody,
|
||||||
|
Spinner,
|
||||||
|
Badge,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
|
/* State Init */
|
||||||
|
let taggers = $state([]);
|
||||||
|
let message = $state({ msg: "", color: "#d63384" });
|
||||||
|
let displayMessage = $state(false);
|
||||||
|
let pollTimer = $state(null);
|
||||||
|
|
||||||
|
/* Functions */
|
||||||
|
async function fetchTaggers() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/config/taggers/");
|
||||||
|
if (res.ok) {
|
||||||
|
taggers = await res.json();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch taggers:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTagger(name) {
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append("name", name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/config/taggers/run/", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
let text = await res.text();
|
||||||
|
popMessage(text, "#048109");
|
||||||
|
startPolling();
|
||||||
|
await fetchTaggers();
|
||||||
|
} else {
|
||||||
|
let text = await res.text();
|
||||||
|
throw new Error("Response Code " + res.status + " -> " + text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
popMessage(err, "#d63384");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) return;
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
await fetchTaggers();
|
||||||
|
const anyRunning = taggers.some((t) => t.running);
|
||||||
|
if (!anyRunning) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function popMessage(response, rescolor) {
|
||||||
|
message = { msg: response, color: rescolor };
|
||||||
|
displayMessage = true;
|
||||||
|
setTimeout(function () {
|
||||||
|
displayMessage = false;
|
||||||
|
}, 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lifecycle */
|
||||||
|
onMount(async () => {
|
||||||
|
await fetchTaggers();
|
||||||
|
const anyRunning = taggers.some((t) => t.running);
|
||||||
|
if (anyRunning) startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Col>
|
||||||
|
<Card class="h-100">
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle class="mb-3">Job Taggers</CardTitle>
|
||||||
|
<p>Run individual taggers on all existing jobs.</p>
|
||||||
|
{#if taggers.length === 0}
|
||||||
|
<p class="text-muted">No taggers available.</p>
|
||||||
|
{:else}
|
||||||
|
<table class="table table-sm mb-3">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each taggers as tagger}
|
||||||
|
<tr>
|
||||||
|
<td>{tagger.name}</td>
|
||||||
|
<td><Badge color="secondary">{tagger.type}</Badge></td>
|
||||||
|
<td>
|
||||||
|
{#if tagger.running}
|
||||||
|
<Spinner size="sm" color="primary" /> Running
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Idle</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
disabled={tagger.running}
|
||||||
|
onclick={() => runTagger(tagger.name)}
|
||||||
|
>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
<p>
|
||||||
|
{#if displayMessage}<b
|
||||||
|
><code style="color: {message.color};" out:fade
|
||||||
|
>{message.msg}</code
|
||||||
|
></b
|
||||||
|
>{/if}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
Reference in New Issue
Block a user