RC-12
release-tag / release-image (push) Successful in 4m48s

This commit is contained in:
2026-08-13 11:17:53 +02:00
parent f2da96ec24
commit 91d598f197
8 changed files with 137 additions and 6 deletions
+17
View File
@@ -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=<task>&id=<task>` (or `?ids=a,b`) for up to 20 queue items.
No database migration is required.
Only the server image needs to be rebuilt.
Regular → Executable
View File
+5
View File
@@ -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
}
+19 -3
View File
@@ -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:
}
}
+66
View File
@@ -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.
+22
View File
@@ -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 {
+7 -3
View File
@@ -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 (110)',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=`<div class="admin-drop-watch">${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 `<div class="admin-drop-watch-row ${esc(st)}"><b>${esc(st.toUpperCase())}</b><code>${esc(id.slice(-14))}</code><span title="${esc(err)}">${esc(info.length>180?info.slice(0,179)+'…':info)}</span>${st==='ready'?`<button type="button" data-drop-open="${esc(id)}">ÖFFNEN</button>`:''}</div>`}).join('')}</div>`;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(){
<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Zeit</th><th>Typ</th><th>Modell</th><th>Tokens (Text + Bild → Output)</th><th>Kosten</th><th>Request</th></tr></thead><tbody id="artifactUsageRows">${usageRows}</tbody></table></div>
<p class="small">Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab <b>TASK ACTIONS</b>.</p></div>`;
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 120 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 120 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=`<div class="control-section"><div class="section-title">LEGACY ARTIFACT GENERATION</div><div class="provider-status">${['local','openai','comfyui','a1111'].map(k=>`<span class="${ps[k]?'ready':'off'}">${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}</span>`).join('')}</div>
<label><span>Provider</span><select data-setting-string="artifact_provider"><option value="local">local</option><option value="openai">openai</option><option value="comfyui">comfyui</option><option value="a1111">a1111</option><option value="auto">auto fallback</option></select></label>
@@ -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());
+1
View File
@@ -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}}