From 91d598f1975ab12dfc0df2f791e0e4f8b8258248 Mon Sep 17 00:00:00 2001 From: groot Date: Thu, 13 Aug 2026 11:17:53 +0200 Subject: [PATCH] RC-12 --- V4.2.8.1_ADMIN_DROP_QUEUE_FIX.md | 17 ++++++++ docker/worker-entrypoint.sh | 0 internal/artifact/openai.go | 5 +++ internal/artifact/worker.go | 22 +++++++++-- internal/data/store.go | 66 ++++++++++++++++++++++++++++++++ internal/server/server.go | 22 +++++++++++ internal/webui/dist/app.js | 10 +++-- internal/webui/dist/styles.css | 1 + 8 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 V4.2.8.1_ADMIN_DROP_QUEUE_FIX.md mode change 100644 => 100755 docker/worker-entrypoint.sh diff --git a/V4.2.8.1_ADMIN_DROP_QUEUE_FIX.md b/V4.2.8.1_ADMIN_DROP_QUEUE_FIX.md new file mode 100644 index 0000000..9a3e30f --- /dev/null +++ b/V4.2.8.1_ADMIN_DROP_QUEUE_FIX.md @@ -0,0 +1,17 @@ +# Neural Hunt V4.2.8.1 – Admin Drop Queue Fix + +This patch improves the admin NFT gift/drop path introduced in V4.2.8. + +## Changes + +- Admin NFT drops now wake the in-process artifact worker immediately after the database transaction commits. The 3-second polling ticker remains as a fallback. +- The Server Admin UI tracks the exact synthetic drop task IDs independently from the task-list status filter and shows `PENDING`, `GENERATING`, `READY`, or `ERROR` live. +- A completed drop gets an `ÖFFNEN` button directly in the drop status area. +- Circuit-breaker deferrals and provider/style errors are visible in the live drop status instead of looking like a button that did nothing. +- Random admin drops whose inherited custom style file no longer exists fall back to the bundled default style. Normal winner cards keep the stricter behavior. +- The server logs every queued admin drop with owner, count and task IDs. +- New admin-only endpoint: `GET /api/admin/artifacts/status?id=&id=` (or `?ids=a,b`) for up to 20 queue items. + +No database migration is required. + +Only the server image needs to be rebuilt. diff --git a/docker/worker-entrypoint.sh b/docker/worker-entrypoint.sh old mode 100644 new mode 100755 diff --git a/internal/artifact/openai.go b/internal/artifact/openai.go index 709b3bd..c12c9be 100644 --- a/internal/artifact/openai.go +++ b/internal/artifact/openai.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "log" "mime" "mime/multipart" "net/http" @@ -113,6 +114,10 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt return imageResult{}, err } styleRef, err := w.loadStyleReference(x.StyleReference) + if err != nil && strings.EqualFold(strings.TrimSpace(x.Origin), "admin_drop") { + log.Printf("artifact worker admin drop %s: template style %q unavailable (%v); falling back to bundled default style", x.ID, x.StyleReference, err) + styleRef, err = w.loadStyleReference("") + } if err != nil { return imageResult{}, err } diff --git a/internal/artifact/worker.go b/internal/artifact/worker.go index c979ea8..76ed505 100644 --- a/internal/artifact/worker.go +++ b/internal/artifact/worker.go @@ -30,6 +30,7 @@ type Worker struct { publicBase string settings *settings.Manager http *http.Client + wake chan struct{} budgetHoldMu sync.Mutex budgetHoldUntil time.Time } @@ -51,6 +52,7 @@ func New(db *sql.DB, dir string, sm *settings.Manager) (*Worker, error) { publicBase: strings.TrimRight(os.Getenv("ARTIFACT_PUBLIC_BASE_URL"), "/"), settings: sm, http: &http.Client{Timeout: envDuration("ARTIFACT_HTTP_TIMEOUT", 4*time.Minute)}, + wake: make(chan struct{}, 1), }, nil } @@ -86,10 +88,24 @@ func (w *Worker) Run(ctx context.Context) { case <-ctx.Done(): return case <-t.C: - if err := w.one(ctx); err != nil { - log.Printf("artifact worker: %v", err) - } + case <-w.wake: } + if err := w.one(ctx); err != nil { + log.Printf("artifact worker: %v", err) + } + } +} + +// Notify wakes the artifact worker after an admin action has queued work. The +// channel is deliberately coalescing: one wake-up is enough even if an admin +// creates several drops at once. The normal ticker remains as a safety net. +func (w *Worker) Notify() { + if w == nil || w.wake == nil { + return + } + select { + case w.wake <- struct{}{}: + default: } } diff --git a/internal/data/store.go b/internal/data/store.go index dc65fa7..fa8f8d8 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -1168,6 +1168,72 @@ func (s *Store) TransferArtifact(ctx context.Context, taskID, targetClientID, re return from, nil } +// AdminArtifactQueueItem is a compact admin-only view of queued/generated +// collectibles. It is intentionally independent from the task list filters so +// an admin drop stays observable even when the UI is currently showing only +// active hunt tasks. +type AdminArtifactQueueItem struct { + TaskID string `json:"task_id"` + ArtifactOrigin string `json:"artifact_origin"` + ArtifactStatus string `json:"artifact_status"` + ArtifactError *string `json:"artifact_error,omitempty"` + ArtifactURI *string `json:"artifact_uri,omitempty"` + OwnerClientID string `json:"owner_client_id"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +func (s *Store) AdminArtifactQueueItems(ctx context.Context, ids []string) ([]AdminArtifactQueueItem, error) { + clean := make([]string, 0, len(ids)) + seen := map[string]struct{}{} + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + clean = append(clean, id) + if len(clean) >= 20 { + break + } + } + if len(clean) == 0 { + return []AdminArtifactQueueItem{}, nil + } + marks := make([]string, len(clean)) + args := make([]any, len(clean)) + for i, id := range clean { + marks[i] = "?" + args[i] = id + } + rows, err := s.DB.QueryContext(ctx, `SELECT id,artifact_origin,artifact_status,artifact_error,artifact_uri,COALESCE(artifact_owner_client_id,winner_client_id,''),created_at + FROM tasks WHERE id IN (`+strings.Join(marks, ",")+`) ORDER BY created_at`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]AdminArtifactQueueItem, 0, len(clean)) + for rows.Next() { + var x AdminArtifactQueueItem + var aerr, uri sql.NullString + if err := rows.Scan(&x.TaskID, &x.ArtifactOrigin, &x.ArtifactStatus, &aerr, &uri, &x.OwnerClientID, &x.CreatedAtMS); err != nil { + return nil, err + } + if aerr.Valid { + v := aerr.String + x.ArtifactError = &v + } + if uri.Valid { + v := uri.String + x.ArtifactURI = &v + } + out = append(out, x) + } + return out, rows.Err() +} + // CreateAdminArtifactDrop queues a collectible for an existing client without // recording a game win. A random task is used as the art-direction template // when templateTaskID is empty; the new seed/id still randomize collection traits. diff --git a/internal/server/server.go b/internal/server/server.go index 85b32bf..4ad8065 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -345,6 +345,7 @@ func (s *Server) Routes() http.Handler { r.Put("/api/admin/tasks/{id}/config", s.adminTaskConfigPut) r.Post("/api/admin/tasks/{id}/artifact/transfer", s.adminArtifactTransfer) r.Post("/api/admin/artifacts/drop", s.adminArtifactDrop) + r.Get("/api/admin/artifacts/status", s.adminArtifactStatus) r.Post("/api/admin/tasks/{id}/actions", s.adminScheduleAction) r.Post("/api/admin/actions/{id}/cancel", s.adminCancelAction) r.Get("/api/admin/artifact/providers", s.adminArtifactProviders) @@ -1570,9 +1571,30 @@ func (s *Server) adminArtifactDrop(w http.ResponseWriter, r *http.Request) { } created = append(created, t.ID) } + if s.artifactWorker != nil { + s.artifactWorker.Notify() + } + log.Printf("admin artifact drop queued: owner=%s count=%d tasks=%s", strings.TrimSpace(in.TargetClientID), len(created), strings.Join(created, ",")) jsonOut(w, 201, map[string]any{"ok": true, "created_task_ids": created, "artifact_status": "pending"}) } +func (s *Server) adminArtifactStatus(w http.ResponseWriter, r *http.Request) { + ids := append([]string(nil), r.URL.Query()["id"]...) + if raw := strings.TrimSpace(r.URL.Query().Get("ids")); raw != "" { + ids = append(ids, strings.Split(raw, ",")...) + } + if len(ids) == 0 { + jsonOut(w, 400, map[string]string{"error": "at least one id is required"}) + return + } + items, err := s.store.AdminArtifactQueueItems(r.Context(), ids) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "artifact status lookup failed: " + err.Error()}) + return + } + jsonOut(w, 200, items) +} + func (s *Server) adminTaskConfigPut(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var in struct { diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index 9726d07..a0bd079 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -461,13 +461,16 @@ function adminShell(){ async function runAdmin(){ let adminOK=false;try{await api('/api/admin/session',{},true);adminOK=true}catch{}if(!adminOK){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');location.reload()}catch(e){$('adminerr').textContent=e.message}};return} - adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminGuessFlashKey='neuralhunt.adminGuessFlash.v1';let adminGuessFlash=false;try{adminGuessFlash=localStorage.getItem(adminGuessFlashKey)==='1'}catch{}map.guessFlashEnabled=adminGuessFlash;setActive('adminGuessFlash',adminGuessFlash);const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,clients=[],poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false; + adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminGuessFlashKey='neuralhunt.adminGuessFlash.v1';let adminGuessFlash=false;try{adminGuessFlash=localStorage.getItem(adminGuessFlashKey)==='1'}catch{}map.guessFlashEnabled=adminGuessFlash;setActive('adminGuessFlash',adminGuessFlash);const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,clients=[],poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false,adminDropWatchIDs=[],adminDropWatchTimer=null; const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)}; const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()}; $('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile(); const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision']; const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (1–10)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'}; const msg=s=>$('adminstatus').textContent=s; + const renderAdminDropWatch=items=>{const host=$('adminDropResult');if(!host)return;const rows=Array.isArray(items)?items:[];if(!rows.length){host.textContent=adminDropWatchIDs.length?'Queue-Einträge werden gesucht …':'';return}const order={error:0,generating:1,pending:2,ready:3};rows.sort((a,b)=>(order[a.artifact_status]??9)-(order[b.artifact_status]??9));host.innerHTML=`
${rows.map(x=>{const st=String(x.artifact_status||'unknown'),err=String(x.artifact_error||'').trim(),id=String(x.task_id||'');let info=st==='pending'?'wartet auf Artifact-Worker':st==='generating'?'Bild wird gerade erzeugt':st==='ready'?'Collectible fertig':st==='error'?(err||'Generierung fehlgeschlagen'):st;return `
${esc(st.toUpperCase())}${esc(id.slice(-14))}${esc(info.length>180?info.slice(0,179)+'…':info)}${st==='ready'?``:''}
`}).join('')}
`;host.querySelectorAll('[data-drop-open]').forEach(b=>b.onclick=()=>openAdminFile(b.dataset.dropOpen,'artifact'))}; + const refreshAdminDropWatch=async()=>{clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(!adminDropWatchIDs.length)return;try{const rows=await api(`/api/admin/artifacts/status?ids=${encodeURIComponent(adminDropWatchIDs.join(','))}`,{},true);renderAdminDropWatch(rows);const active=(rows||[]).some(x=>x.artifact_status==='pending'||x.artifact_status==='generating');if(active){adminDropWatchTimer=setTimeout(refreshAdminDropWatch,2000)}else{adminDropWatchIDs=[];setTimeout(()=>load(true,false),0)}}catch(e){const host=$('adminDropResult');if(host)host.textContent='Queue-Status konnte nicht geladen werden: '+(e.message||e);adminDropWatchTimer=setTimeout(refreshAdminDropWatch,4000)}}; + const watchAdminDrops=ids=>{adminDropWatchIDs=(Array.isArray(ids)?ids:[]).map(String).filter(Boolean).slice(0,20);clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(adminDropWatchIDs.length)refreshAdminDropWatch()}; let adminSignalWS=null,adminSignalTimer=null,adminSignalBackoff=500,adminSignalClosed=false; const closeAdminSignalWS=()=>{clearTimeout(adminSignalTimer);adminSignalTimer=null;if(adminSignalWS){adminSignalWS._plannedClose=true;try{adminSignalWS.close(1000,'disabled')}catch{}adminSignalWS=null}}; const openAdminSignalWS=()=>{if(!adminGuessFlash||adminSignalClosed)return;if(adminSignalWS&&(adminSignalWS.readyState===WebSocket.OPEN||adminSignalWS.readyState===WebSocket.CONNECTING))return;const proto=location.protocol==='https:'?'wss':'ws',socket=new WebSocket(`${proto}://${location.host}/api/admin/ws`);adminSignalWS=socket;socket.onopen=()=>{if(adminSignalWS!==socket)return;adminSignalBackoff=500};socket.onmessage=ev=>{if(adminSignalWS!==socket||!adminGuessFlash)return;try{const e=JSON.parse(ev.data);if(e.type==='guess_signal'&&selected?.id===e.task_id)map.flashGuess(e.data)}catch{}};socket.onclose=()=>{if(adminSignalWS===socket)adminSignalWS=null;if(!socket._plannedClose&&adminGuessFlash&&!adminSignalClosed){clearTimeout(adminSignalTimer);adminSignalTimer=setTimeout(openAdminSignalWS,adminSignalBackoff);adminSignalBackoff=Math.min(10000,adminSignalBackoff*1.8)}}}; @@ -511,8 +514,9 @@ async function runAdmin(){
${usageRows}
ZeitTypModellTokens (Text + Bild → Output)KostenRequest

Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab TASK ACTIONS.

`; if(anchorReady){const img=$('anchorPreview');loadProtectedImage('/api/admin/artifact/character-anchor?ts='+Date.now(),img,true).catch(()=>{if(img)img.alt='Anchor konnte nicht geladen werden'})} + if(adminDropWatchIDs.length)setTimeout(refreshAdminDropWatch,0); const create=$('createCharacterAnchor');if(create)create.onclick=async()=>{if(!confirm('RIFT Character Anchor jetzt einmalig erzeugen? Danach wird er absichtlich nicht automatisch überschrieben.'))return;create.disabled=true;create.textContent='ANCHOR WIRD ERZEUGT …';try{await api('/api/admin/artifact/character-anchor',{method:'POST'},true);msg('RIFT Character Anchor erzeugt und gesperrt');await load(true,true)}catch(e){msg(e.message);create.disabled=false;create.textContent='RIFT-ANCHOR JETZT ERZEUGEN'}}; - const drop=$('createAdminDrop');if(drop)drop.onclick=async()=>{const target=String($('dropTargetClient')?.value||'').trim(),count=Number($('dropCount')?.value||1),template=String($('dropTemplateTask')?.value||'').trim();if(!target){msg('Ziel Client-ID fehlt');return}if(!Number.isInteger(count)||count<1||count>20){msg('Anzahl muss 1–20 sein');return}if(!confirm(`${count} Admin-NFT${count===1?'':'s'} für ${target.slice(0,18)}… erzeugen?\n\nDies kann API-Kosten auslösen; der Circuit-Breaker bleibt aktiv.`))return;drop.disabled=true;drop.textContent='WIRD EINGEREIHT …';try{const r=await api('/api/admin/artifacts/drop',{method:'POST',body:JSON.stringify({target_client_id:target,template_task_id:template,count})},true);$('adminDropResult').textContent=`${(r.created_task_ids||[]).length} Collectible(s) pending · ${(r.created_task_ids||[]).map(x=>x.slice(-10)).join(', ')}`;msg('Admin NFT-Drop eingereiht');await load(true,false)}catch(e){msg(e.message)}finally{drop.disabled=false;drop.textContent='NFT-DROP IN QUEUE STELLEN'}}; + const drop=$('createAdminDrop');if(drop)drop.onclick=async()=>{const target=String($('dropTargetClient')?.value||'').trim(),count=Number($('dropCount')?.value||1),template=String($('dropTemplateTask')?.value||'').trim();if(!target){msg('Ziel Client-ID fehlt');return}if(!Number.isInteger(count)||count<1||count>20){msg('Anzahl muss 1–20 sein');return}if(!confirm(`${count} Admin-NFT${count===1?'':'s'} für ${target.slice(0,18)}… erzeugen?\n\nDies kann API-Kosten auslösen; der Circuit-Breaker bleibt aktiv.`))return;drop.disabled=true;drop.textContent='WIRD EINGEREIHT …';try{const r=await api('/api/admin/artifacts/drop',{method:'POST',body:JSON.stringify({target_client_id:target,template_task_id:template,count})},true);const ids=r.created_task_ids||[];$('adminDropResult').textContent=`${ids.length} Collectible(s) eingereiht · Status wird verfolgt …`;watchAdminDrops(ids);msg('Admin NFT-Drop eingereiht · Artifact-Worker wurde geweckt');await load(true,false)}catch(e){msg(e.message)}finally{drop.disabled=false;drop.textContent='NFT-DROP IN QUEUE STELLEN'}}; }else{ $('settingfields').innerHTML=`
LEGACY ARTIFACT GENERATION
${['local','openai','comfyui','a1111'].map(k=>`${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}`).join('')}
@@ -577,7 +581,7 @@ async function runAdmin(){ $('savesettings').onclick=async()=>{try{captureDraft();const out={...settings};document.querySelectorAll('[data-setting]').forEach(i=>out[i.dataset.setting]=Number(i.value));document.querySelectorAll('[data-setting-string]').forEach(i=>out[i.dataset.settingString]=i.value);settings=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(out)},true);if(draft.fields)delete draft.fields[draftScope()];saveDraft();msg('gespeichert');renderSettings()}catch(e){msg(e.message)}}; $('ensuretasks').onclick=async()=>{try{await api('/api/admin/tasks/ensure',{method:'POST'},true);msg('aktive Tasks sichergestellt');await load(true,true)}catch(e){msg(e.message)}}; $('adminlogout').onclick=async()=>{try{await fetch('/api/admin/logout',{method:'POST',credentials:'same-origin'})}finally{location.reload()}}; - $('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{adminSignalClosed=true;closeAdminSignalWS();captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true}); + $('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{adminSignalClosed=true;closeAdminSignalWS();clearTimeout(adminDropWatchTimer);captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true}); } applyMobileMode(mobileModeEnabled()); diff --git a/internal/webui/dist/styles.css b/internal/webui/dist/styles.css index bf6bdac..f03438f 100644 --- a/internal/webui/dist/styles.css +++ b/internal/webui/dist/styles.css @@ -115,3 +115,4 @@ html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-te .beacon-buttons button.active{border-color:#fff;box-shadow:0 0 18px rgba(255,255,255,.14);background:rgba(255,255,255,.13)} .beacon-choice>small{display:block;opacity:.68;line-height:1.35} .admin-drop-box,.ownership-box{margin-top:12px}.admin-drop-box #adminDropResult{margin-top:8px;color:#8edbb8}.task-facts{grid-template-columns:repeat(4,minmax(0,1fr))}@media(max-width:720px){.task-facts{grid-template-columns:repeat(2,minmax(0,1fr))}} +.admin-drop-watch{display:grid;gap:5px;margin-top:8px}.admin-drop-watch-row{display:grid;grid-template-columns:78px 112px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 8px;border:1px solid rgba(133,200,255,.10);border-radius:9px;background:rgba(255,255,255,.018);font-size:8px}.admin-drop-watch-row b{font-size:7px;letter-spacing:.09em}.admin-drop-watch-row code{font-size:7px;color:#8fbdcf}.admin-drop-watch-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#819dac}.admin-drop-watch-row.generating b{color:#8feeff}.admin-drop-watch-row.ready b{color:#7dffc3}.admin-drop-watch-row.error{border-color:rgba(255,95,136,.28)}.admin-drop-watch-row.error b,.admin-drop-watch-row.error span{color:#ff9bad}.admin-drop-watch-row button{padding:5px 7px}@media(max-width:720px){.admin-drop-watch-row{grid-template-columns:70px 1fr}.admin-drop-watch-row span{grid-column:1/-1;white-space:normal}.admin-drop-watch-row button{grid-column:1/-1}}