This commit is contained in:
13
Dockerfile
13
Dockerfile
@@ -1,14 +1,13 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.* ./
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
RUN go mod tidy
|
||||
COPY . .
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt ./cmd/server
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt-client ./cmd/client
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt ./cmd/server \
|
||||
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt-client ./cmd/client
|
||||
|
||||
FROM alpine:3.24
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -H app && mkdir -p /data /app && chown -R app:app /data /app
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/neuralhunt /app/neuralhunt
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Neural Hunt — V2.8 RIFT Task-Style Collection
|
||||
# Neural Hunt — V3.1 RIFT Task-Style Collection + Profile Cleanup
|
||||
|
||||
|
||||
|
||||
> **V3.1 Admin Profile Cleanup:** Im Admin-Tab **RUNTIME** gibt es ein manuelles Bereinigungstool für alte Identitäten. Die Inaktivitätsdauer ist in Stunden/Tagen/Wochen einstellbar. Vor dem Löschen zeigt **PRÜFEN** die Anzahl löschbarer Profile. Gelöscht werden ausschließlich Profile, deren letzte Aktivität älter als die gewählte Grenze ist, die aktuell nicht verbunden sind und die niemals Gewinner eines Tasks waren. Gewinner werden immer geschützt; aktuell verbundene Clients ebenfalls. Beim Löschen werden die per Foreign Key abhängigen `task_points`, `client_unlocks` und `client_task_selection` mit entfernt. WebSocket-Verbindungsaufbau und -ende aktualisieren `clients.last_seen`, damit die Inaktivitätsgrenze tatsächliche Nutzung besser abbildet.
|
||||
|
||||
> **V2.8 RIFT Task-Styles:** RIFT-Identität und Rendering-Stil sind jetzt sauber getrennt. `/data/artifacts/_collection/character_anchor.png` ist ein globaler, neutraler Identity-Lock für den Waschbären RIFT und kann im Admin-Tab **ARTIFACT** einmalig manuell erzeugt und geprüft werden. Jeder Task kann im Admin-Tab **TASK ACTIONS** ein eigenes JPEG-/PNG-Style-Referenzbild erhalten; Nutzer sehen dieses Stylebild bereits in der Task-Auswahl und wählen damit indirekt die gewünschte NFT-Art. Bei jeder RIFT-Karte sendet Neural Hunt **Image 1 = Character Anchor** und **Image 2 = Task Style Reference** an die Images Edit API. Ohne eigenen Task-Style bleibt `internal/artifact/assets/style_reference.jpg` nur noch der Default-Fallback. Folge-Tasks erben ihren Style. `medium` bleibt Standard und die OpenAI-Usage-/Kosten-KPIs aus V2.7 bleiben erhalten.
|
||||
|
||||
|
||||
|
||||
13
TESTING.md
13
TESTING.md
@@ -319,3 +319,16 @@ go test ./internal/webui ./internal/core ./internal/auth ./internal/artifact ./i
|
||||
- Complete a task and verify its successor inherits `nft_style_reference`.
|
||||
- Generate a RIFT winner card and inspect the multipart OpenAI edit request: `image[]` must contain two files in this order: `character_anchor.png`, then the task style reference. Provider metadata should contain `reference_mode=character-plus-task-style`, the character-anchor hash and the style-reference hash.
|
||||
- Use **AUF DEFAULT ZURÜCK** and verify the task DB reference is empty and generation falls back to `internal/artifact/assets/style_reference.jpg` without deleting shared content-addressed style files.
|
||||
|
||||
|
||||
## V3.1 — Admin cleanup for stale non-winner profiles
|
||||
|
||||
- In Admin → RUNTIME set e.g. `30 Tage` and click **PRÜFEN**. Verify the preview reports only clients whose `clients.last_seen` is older than the cutoff, that are not currently connected, and that have never appeared as `tasks.winner_client_id`.
|
||||
- Keep an old client connected via WebSocket: it must be reported as **aktuell verbunden geschützt** and never be deleted.
|
||||
- Create an old winner identity: it must be reported as **Gewinner geschützt** and never be deleted, regardless of age.
|
||||
- Confirm deletion and verify the client row is removed together with cascading `task_points`, `client_unlocks`, and `client_task_selection` rows.
|
||||
- Verify a recent non-winner remains untouched.
|
||||
- Verify WebSocket connect and disconnect update `clients.last_seen`, so a long-running session starts its inactivity window at disconnect rather than at its original login.
|
||||
- The API rejects cleanup windows shorter than one hour.
|
||||
|
||||
Relevant automated tests: `internal/data/profile_cleanup_test.go` and `internal/server/profile_cleanup_test.go`.
|
||||
|
||||
@@ -100,6 +100,21 @@ func (s *State) ConnectedCount() int64 {
|
||||
return int64(len(s.presence))
|
||||
}
|
||||
|
||||
// ForgetClient removes non-durable runtime state for an identity that was
|
||||
// deleted by the admin profile cleanup tool. The caller must only pass clients
|
||||
// that are currently disconnected.
|
||||
func (s *State) ForgetClient(clientID string) {
|
||||
s.mu.Lock()
|
||||
delete(s.presence, clientID)
|
||||
delete(s.selected, clientID)
|
||||
for k := range s.guesses {
|
||||
if k.ClientID == clientID {
|
||||
delete(s.guesses, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *State) SetTaskSelection(clientID, taskID string) {
|
||||
s.mu.Lock()
|
||||
if taskID == "" {
|
||||
|
||||
55
internal/server/profile_cleanup_test.go
Normal file
55
internal/server/profile_cleanup_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
"neuralhunt/internal/data"
|
||||
rtx "neuralhunt/internal/runtime"
|
||||
)
|
||||
|
||||
func TestProfileCleanupPreviewProtectsConnectedClient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := data.OpenSQLite(ctx, t.TempDir()+"/cleanup.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := data.New(db)
|
||||
jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"}
|
||||
for _, id := range []string{"offline_old", "online_old"} {
|
||||
if err := store.UpsertClient(ctx, id, jwk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
old := time.Now().UTC().Add(-10 * 24 * time.Hour).UnixMilli()
|
||||
if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=?`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeState := rtx.New()
|
||||
if _, err := runtimeState.AcquirePresence("online_old", "session"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{store: store, runtime: runtimeState}
|
||||
preview, ids, err := s.profileCleanupPreview(ctx, 7*24*60*60)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.Eligible != 1 || preview.ProtectedConnected != 1 {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != "offline_old" {
|
||||
t.Fatalf("unexpected ids: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCleanupDurationGuard(t *testing.T) {
|
||||
if _, err := profileCleanupDurationSeconds("3599"); err == nil {
|
||||
t.Fatal("sub-hour cleanup window should be rejected")
|
||||
}
|
||||
if got, err := profileCleanupDurationSeconds("86400"); err != nil || got != 86400 {
|
||||
t.Fatalf("got=%d err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,8 @@ func (s *Server) Routes() http.Handler {
|
||||
r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) })
|
||||
r.Get("/api/admin/overview", s.adminOverview)
|
||||
r.Get("/api/admin/performance", s.adminPerformance)
|
||||
r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview)
|
||||
r.Post("/api/admin/profiles/cleanup", s.adminProfileCleanup)
|
||||
r.Get("/api/admin/settings", s.adminSettingsGet)
|
||||
r.Put("/api/admin/settings", s.adminSettingsPut)
|
||||
r.Get("/api/admin/tasks", s.adminTasks)
|
||||
@@ -736,6 +738,108 @@ func (s *Server) serveAdminArtifactPart(w http.ResponseWriter, r *http.Request,
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
type profileCleanupPreview struct {
|
||||
CutoffMS int64 `json:"cutoff_ms"`
|
||||
InactiveForSeconds int64 `json:"inactive_for_seconds"`
|
||||
Eligible int `json:"eligible"`
|
||||
ProtectedWinners int64 `json:"protected_winners"`
|
||||
ProtectedConnected int `json:"protected_connected"`
|
||||
OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"`
|
||||
NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"`
|
||||
}
|
||||
|
||||
func profileCleanupDurationSeconds(raw string) (int64, error) {
|
||||
seconds, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("inactive_for_seconds must be an integer")
|
||||
}
|
||||
// A one-hour minimum prevents an accidental near-live purge while still
|
||||
// allowing short-lived development/test deployments to clean up quickly.
|
||||
if seconds < 3600 || seconds > 10*365*24*60*60 {
|
||||
return 0, errors.New("inactive_for_seconds must be between 3600 seconds and 10 years")
|
||||
}
|
||||
return seconds, nil
|
||||
}
|
||||
|
||||
func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds int64) (profileCleanupPreview, []string, error) {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(inactiveForSeconds) * time.Second).UnixMilli()
|
||||
candidates, err := s.store.InactiveNonWinnerClients(ctx, cutoff)
|
||||
if err != nil {
|
||||
return profileCleanupPreview{}, nil, err
|
||||
}
|
||||
protectedWinners, err := s.store.OldWinnerCount(ctx, cutoff)
|
||||
if err != nil {
|
||||
return profileCleanupPreview{}, nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(candidates))
|
||||
out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners}
|
||||
for _, c := range candidates {
|
||||
if s.runtime.IsConnected(c.ClientID) {
|
||||
out.ProtectedConnected++
|
||||
continue
|
||||
}
|
||||
ids = append(ids, c.ClientID)
|
||||
if out.OldestEligibleMS == 0 || c.LastSeen < out.OldestEligibleMS {
|
||||
out.OldestEligibleMS = c.LastSeen
|
||||
}
|
||||
if c.LastSeen > out.NewestEligibleMS {
|
||||
out.NewestEligibleMS = c.LastSeen
|
||||
}
|
||||
}
|
||||
out.Eligible = len(ids)
|
||||
return out, ids, nil
|
||||
}
|
||||
|
||||
func (s *Server) adminProfileCleanupPreview(w http.ResponseWriter, r *http.Request) {
|
||||
seconds, err := profileCleanupDurationSeconds(r.URL.Query().Get("inactive_for_seconds"))
|
||||
if err != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
preview, _, err := s.profileCleanupPreview(r.Context(), seconds)
|
||||
if err != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": "profile cleanup preview failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, preview)
|
||||
}
|
||||
|
||||
func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
InactiveForSeconds int64 `json:"inactive_for_seconds"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
||||
return
|
||||
}
|
||||
seconds, err := profileCleanupDurationSeconds(strconv.FormatInt(in.InactiveForSeconds, 10))
|
||||
if err != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
preview, ids, err := s.profileCleanupPreview(r.Context(), seconds)
|
||||
if err != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": "profile cleanup check failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
deleted, err := s.store.DeleteInactiveNonWinnerClients(r.Context(), preview.CutoffMS, ids)
|
||||
if err != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": "profile cleanup failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
for _, id := range deleted {
|
||||
s.runtime.ForgetClient(id)
|
||||
}
|
||||
jsonOut(w, 200, map[string]any{
|
||||
"deleted": len(deleted),
|
||||
"eligible_before": preview.Eligible,
|
||||
"protected_winners": preview.ProtectedWinners,
|
||||
"protected_connected": preview.ProtectedConnected,
|
||||
"cutoff_ms": preview.CutoffMS,
|
||||
"inactive_for_seconds": seconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
var clients, activeTasks, completedTasks, guesses, artifacts int64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM clients`).Scan(&clients)
|
||||
@@ -1062,6 +1166,10 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "identity not registered", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// WebSocket use counts as recent profile activity. This also makes a race
|
||||
// with the admin cleanup safe: a newly connecting identity no longer
|
||||
// matches an old last_seen cutoff.
|
||||
s.store.TouchClient(r.Context(), c.ClientID)
|
||||
t, err := s.store.TaskForClient(r.Context(), c.ClientID)
|
||||
if err != nil {
|
||||
http.Error(w, "no task", 503)
|
||||
@@ -1080,7 +1188,13 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cl := wsx.NewClient(conn, t.ID, c.ClientID, false)
|
||||
s.hub.Add(cl)
|
||||
defer func() { s.hub.Remove(cl); s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID) }()
|
||||
defer func() {
|
||||
s.hub.Remove(cl)
|
||||
s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID)
|
||||
// Mark the disconnect time as last activity. A client that stayed online
|
||||
// for days therefore starts its inactivity window only after disconnect.
|
||||
s.store.TouchClient(context.Background(), c.ClientID)
|
||||
}()
|
||||
|
||||
// A map point is durable only once per client/task. Subsequent losing guesses
|
||||
// stay in memory; improvements are checkpointed by the guess handler.
|
||||
|
||||
11
internal/webui/dist/app.js
vendored
11
internal/webui/dist/app.js
vendored
@@ -432,7 +432,16 @@ async function runAdmin(){
|
||||
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
|
||||
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>`<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span>${esc(t.artifact_status||'—')}<small>${t.parent_task_id?`↳ ${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small><small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
|
||||
function renderRuntime(){
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
|
||||
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
|
||||
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
|
||||
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
|
||||
</div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
|
||||
const cleanupSeconds=()=>{const v=Math.max(1,Number($('profileCleanupValue')?.value||0)),unit=$('profileCleanupUnit')?.value||'days',factor=unit==='hours'?3600:unit==='weeks'?7*86400:86400;return Math.round(v*factor)};
|
||||
const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
|
||||
const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}};
|
||||
$('previewProfileCleanup').onclick=()=>previewCleanup().catch(()=>{});
|
||||
$('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}};
|
||||
}
|
||||
function renderArtifact(){
|
||||
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[];
|
||||
|
||||
3
internal/webui/dist/styles.css
vendored
3
internal/webui/dist/styles.css
vendored
@@ -98,3 +98,6 @@ html.mobile-mode .task-landing-inner{width:calc(100% - 16px);padding:82px 0 22px
|
||||
html.mobile-mode .cost-grid{grid-template-columns:1fr}.mobile-mode .cost-card{padding:8px}.mobile-mode .usage-table-wrap{max-width:100%}
|
||||
.reference-admin-box,.task-style-admin{display:grid;grid-template-columns:minmax(0,1fr) 128px;gap:10px;align-items:stretch;border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin:10px 0;background:rgba(82,231,255,.025)}.reference-preview{min-height:128px;border:1px dashed rgba(133,200,255,.18);border-radius:10px;display:grid;place-items:center;overflow:hidden;background:rgba(2,8,17,.72);color:#607f92;font-size:7px;letter-spacing:.12em}.reference-preview.has-image{border-style:solid}.reference-preview img{width:100%;height:100%;min-height:128px;display:block;object-fit:cover}.ready-copy{color:#8edbb8}.anchor-actions{margin:0 0 12px}.task-style-admin{grid-template-columns:128px minmax(0,1fr);margin:10px 0 12px}.task-style-controls{min-width:0}.task-style-controls .section-title{margin-top:0}.task-style-controls input[type=file]{width:100%;font-size:7px;color:#7898aa;border:1px solid rgba(133,200,255,.12);border-radius:8px;padding:6px;background:rgba(2,8,17,.55)}.style-actions{justify-content:flex-start;flex-wrap:wrap}.danger-button{color:#ff9bad!important;border-color:rgba(255,95,136,.22)!important;background:rgba(255,95,136,.04)!important}.control-section button:disabled{opacity:.45;cursor:not-allowed}
|
||||
html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-template-columns:1fr}.mobile-mode .reference-preview{min-height:160px}.mobile-mode .reference-preview img{min-height:160px;max-height:240px}
|
||||
|
||||
/* Admin-only cleanup for stale non-winner identities. */
|
||||
.profile-cleanup{margin-top:12px;border-top:1px solid rgba(133,200,255,.12);padding-top:14px}.cleanup-controls{display:grid;grid-template-columns:minmax(0,1fr) minmax(110px,.7fr);gap:8px;margin:9px 0}.cleanup-actions{display:flex;gap:7px;flex-wrap:wrap}.cleanup-result{margin-top:9px;border:1px solid rgba(133,200,255,.11);background:rgba(2,8,17,.55);border-radius:9px;padding:9px;color:#88a6b8;font-size:8px;line-height:1.5}.cleanup-result b{color:#dffaff;font-size:11px}.cleanup-result span{margin-left:5px}.cleanup-result small{display:block;color:#64879a;margin-top:3px}.profile-cleanup .danger-button{margin-left:auto}@media(max-width:520px){.cleanup-controls{grid-template-columns:1fr}.profile-cleanup .danger-button{margin-left:0}.cleanup-actions button{flex:1}}
|
||||
|
||||
90
run-load.ps1
90
run-load.ps1
@@ -1,45 +1,45 @@
|
||||
param(
|
||||
[switch]$NoEnv
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$ProjectRoot = $PSScriptRoot
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
function Import-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts.Count -ne 2) { return }
|
||||
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if (-not $name) { return }
|
||||
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable($name, $value, "Process")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoEnv) {
|
||||
$envFile = Join-Path $ProjectRoot ".env"
|
||||
if (Test-Path -LiteralPath $envFile) {
|
||||
Import-DotEnv -Path $envFile
|
||||
}
|
||||
else {
|
||||
Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet."
|
||||
}
|
||||
}
|
||||
|
||||
go run ./cmd/loadtest
|
||||
exit $LASTEXITCODE
|
||||
param(
|
||||
[switch]$NoEnv
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$ProjectRoot = $PSScriptRoot
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
function Import-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts.Count -ne 2) { return }
|
||||
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if (-not $name) { return }
|
||||
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable($name, $value, "Process")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoEnv) {
|
||||
$envFile = Join-Path $ProjectRoot ".env"
|
||||
if (Test-Path -LiteralPath $envFile) {
|
||||
Import-DotEnv -Path $envFile
|
||||
}
|
||||
else {
|
||||
Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet."
|
||||
}
|
||||
}
|
||||
|
||||
go run ./cmd/loadtest
|
||||
exit $LASTEXITCODE
|
||||
|
||||
Reference in New Issue
Block a user