Remove dead code: DevView, dev.css, unused formatUsage, gate viewer init

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-01 17:44:52 +02:00
parent d6337c1ece
commit 323bb0113f
4 changed files with 4 additions and 832 deletions

View File

@ -1,275 +0,0 @@
.dev-view {
padding: var(--space-inset) 0;
}
.dev-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-page);
}
.dev-header h2 { margin-bottom: 0; }
.dev-view h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: var(--space-page);
color: var(--text);
}
.dev-section { margin-bottom: var(--space-page); }
.dev-section h3 {
color: var(--text-dim);
margin-bottom: var(--space-gap);
}
.dev-actions { display: flex; gap: var(--space-gap); flex-wrap: wrap; }
/* Credits widget */
.credits-widget {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius, 6px);
padding: 16px 20px;
}
.credits-bar-track {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin-bottom: 14px;
}
.credits-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.4s ease;
}
.credits-row {
display: flex;
gap: 32px;
}
.credits-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.credits-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, var(--text-dim));
}
.credits-amount {
font-size: 1.1rem;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.credits-used { color: var(--error); }
.credits-remaining { color: var(--success-dim, var(--accent)); }
/* Table */
.dev-table {
width: 100%;
border-collapse: collapse;
}
.dev-table th {
text-align: left;
padding: 8px 12px;
color: var(--text-dim);
font-weight: 500;
border-bottom: 1px solid var(--border);
}
.dev-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
.dev-table tr:last-child td { border-bottom: none; }
.dev-table .agent-id { font-weight: 600; }
.dev-table code {
background: var(--border);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-mono);
}
/* Dev flags */
.dev-flags { display: flex; gap: 16px; flex-wrap: wrap; }
.dev-flag {
display: flex;
align-items: center;
gap: 6px;
color: var(--text);
cursor: pointer;
user-select: none;
}
.dev-flag input[type="checkbox"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
.dev-flag span { font-family: var(--font-mono); }
.takeover-token {
font-family: var(--font-mono);
background: var(--surface);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: var(--radius-sm);
color: var(--accent);
user-select: all;
}
.dev-loading { color: var(--text-dim); }
.dev-error { color: var(--error); }
.dev-refresh-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.dev-refresh-btn:hover { color: var(--text); border-color: var(--text-dim); }
.dev-refresh-btn:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; }
.dev-disco-btn {
background: none;
border: 1px solid var(--error);
color: var(--error);
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.dev-disco-btn:hover { background: var(--error)22; }
.dev-disco-btn:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; }
/* Theme buttons */
.dev-theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 6px 18px;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.dev-theme-btn:hover { color: var(--text); border-color: var(--text-dim); }
.dev-theme-btn.active {
border-color: var(--accent);
color: var(--bg);
background: var(--accent);
}
/* Table horizontal scroll wrapper */
.dev-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Breakout confirmation modal */
.breakout-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.breakout-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px 32px;
min-width: 300px;
text-align: center;
}
.breakout-modal h3 { color: var(--text); margin-bottom: 12px; }
.breakout-modal p { color: var(--text-dim); margin: 4px 0; }
.breakout-nonce {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.15em;
margin: 16px 0;
}
.breakout-modal-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 16px;
}
/* ── MCP Counter ── */
.counter-widget { transition: opacity 0.3s; }
.counter-widget.muted { opacity: 0.35; }
.counter-controls { display: flex; align-items: center; gap: 16px; }
.counter-btn {
width: 48px; height: 48px; border-radius: 50%;
border: 1px solid var(--text-dim); background: transparent;
color: var(--text); font-size: 1.5rem; cursor: pointer;
transition: all 0.2s;
}
.counter-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.counter-btn:disabled { cursor: not-allowed; opacity: 0.3; }
.counter-value {
font-size: 2.5rem; font-variant-numeric: tabular-nums;
min-width: 3ch; text-align: center; color: var(--text);
}
.counter-challenge {
margin-top: 12px; display: flex; align-items: center; gap: 12px;
animation: counter-pulse 1s ease-in-out infinite alternate;
}
.counter-message { color: var(--accent); font-weight: 600; font-size: 0.9rem; }
.counter-timer {
font-variant-numeric: tabular-nums; color: var(--text-dim);
font-size: 0.85rem; min-width: 3ch;
}
.counter-hint { margin-top: 8px; font-size: 0.8rem; opacity: 0.4; }
@keyframes counter-pulse {
from { opacity: 0.7; }
to { opacity: 1; }
}
.counter-widget.flash {
animation: counter-flash 0.5s ease-out 3;
}
@keyframes counter-flash {
0% { box-shadow: 0 0 0 0 var(--accent); }
50% { box-shadow: 0 0 20px 4px var(--accent); }
100% { box-shadow: 0 0 0 0 var(--accent); }
}
/* ── Confetti ── */
.confetti-container {
position: fixed; inset: 0; pointer-events: none; z-index: 9999; overflow: hidden;
}
.confetti-piece {
position: absolute; width: 10px; height: 10px; top: -20px;
animation: confetti-fall 3s ease-in forwards;
}
@keyframes confetti-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
/* ── Action Picker ── */
.action-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.action-pick-btn {
padding: 8px 16px; border-radius: 6px;
border: 1px solid var(--accent); background: transparent;
color: var(--accent); font-size: 0.85rem; cursor: pointer;
transition: all 0.2s;
}
.action-pick-btn:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
.action-pick-btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── Mobile ── */
@media (max-width: 639px) {
.dev-theme-btn, .dev-disco-btn { min-height: 44px; }
.dev-flag { min-height: 44px; }
}

View File

@ -1,51 +1,4 @@
import { computed, type Ref } from 'vue';
import type { Agent } from './agents'; // Import Agent interface
export function formatUsage(u: any, agentId: string | null = null, allAgents: Agent[] | null = null): string {
if (!u) return '';
const inn = u.input_tokens ?? u.in ?? null;
const out = u.output_tokens ?? u.out ?? null;
let inCost = 0, outCost = 0, totalCost = 0;
if (agentId && allAgents && inn !== null && out !== null) {
const agent = allAgents.find(a => a.id === agentId);
if (agent && agent.promptPrice !== null && agent.completionPrice !== null) {
inCost = (inn / 1_000_000) * agent.promptPrice;
outCost = (out / 1_000_000) * agent.completionPrice;
totalCost = inCost + outCost;
}
}
// Fallback to existing cost from backend if no agent pricing
if (totalCost === 0) {
totalCost = typeof u.cost === 'object' ? (u.cost?.total ?? 0) : Number(u.cost || 0);
}
const showCosts = totalCost > 0.0000001;
const inCostStr = showCosts && inCost > 0 ? `$${inCost.toFixed(4)}` : '';
const outCostStr = showCosts && outCost > 0 ? `$${outCost.toFixed(4)}` : '';
if (inn !== null && out !== null) {
const inFmt = inn >= 1000 ? (inn / 1000).toFixed(1) + 'k' : String(inn);
const outFmt = out >= 1000 ? (out / 1000).toFixed(1) + 'k' : String(out);
let pricingStr = '';
if (agentId && allAgents) {
const agent = allAgents.find(a => a.id === agentId);
if (agent && agent.promptPrice !== null && agent.completionPrice !== null) {
pricingStr = ` (${agent.promptPrice.toFixed(2)}/${agent.completionPrice.toFixed(2)})`;
}
}
const parts = [`${inFmt} in${inCostStr ? ` (${inCostStr})` : ''}`, `${outFmt} out${outCostStr ? ` (${outCostStr})` : ''}`];
if (showCosts) parts.push(`$${totalCost.toFixed(4)}${pricingStr}`);
return parts.join(' · ');
}
const total = u.total_tokens ?? u.total ?? 0;
return `${total} tokens${showCosts ? ` · $${totalCost.toFixed(4)}` : ''}`;
}
import pkg from '../../package.json';
declare const __BUILD__: string;

View File

@ -10,6 +10,7 @@ import { useWebSocket } from './composables/ws';
import { useAgents } from './composables/agents';
import { useAuth } from './composables/auth';
import { useViewerStore } from './store/viewer';
import tenant from './tenant';
// Viewer last-selected path — reactive singleton so App.vue nav link stays current
export const lastViewerPath = ref(typeof localStorage !== 'undefined' ? localStorage.getItem('viewer_last_path') || '' : '');
@ -31,8 +32,9 @@ agents.setCurrentUser(ws.currentUser);
// Auth — on login/auto-connect, start HTTP transport
export const auth = useAuth(() => {
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
// Pre-warm viewer token in background so agent→viewer is instant
useViewerStore().acquire();
if (tenant.features.viewer) {
useViewerStore().acquire();
}
});
// Auto-connect if valid token exists from previous session (deferred so Pinia is ready)
setTimeout(() => auth.tryAutoConnect(), 0);

View File

@ -1,508 +0,0 @@
<template>
<OverlayScrollbarsComponent class="dev-view" :options="scrollbarOptions" element="div" v-if="isLoggedIn">
<div class="page">
<div class="dev-header">
<h2>/dev</h2>
<div class="dev-actions">
<RouterLink to="/agents" class="dev-disco-btn"><HomeIcon class="w-4 h-4 inline" /> Home</RouterLink>
<button class="dev-disco-btn" @click="disco" :disabled="discoing"><BoltIcon class="w-4 h-4 inline" /> Disconnect Gateway</button>
<button class="dev-disco-btn" @click="discoChat" :disabled="discoChatting"><SignalSlashIcon class="w-4 h-4 inline" /> Disconnect Chat</button>
</div>
</div>
<div class="content">
<!-- Theme -->
<div class="dev-section">
<h3>Theme</h3>
<div class="dev-actions">
<button
class="dev-theme-btn"
:class="{ active: theme === 'loop42' }"
@click="setTheme('loop42')"
><component :is="THEME_ICONS.loop42" class="w-4 h-4 inline" /> loop42</button>
<button
class="dev-theme-btn"
:class="{ active: theme === 'titan' }"
@click="setTheme('titan')"
><component :is="THEME_ICONS.titan" class="w-4 h-4 inline" /> Titan</button>
<button
class="dev-theme-btn"
:class="{ active: theme === 'eras' }"
@click="setTheme('eras')"
><component :is="THEME_ICONS.eras" class="w-4 h-4 inline" /> ERAS</button>
</div>
</div>
<!-- Dev Flags -->
<div class="dev-section">
<h3>Dev Flags</h3>
<div class="dev-flags">
<label class="dev-flag">
<input type="checkbox" v-model="devFlags.showGrid" />
<span>showGrid</span>
</label>
<label class="dev-flag">
<input type="checkbox" v-model="devFlags.showDebugInfo" />
<span>showDebugInfo</span>
</label>
<label class="dev-flag">
<input type="checkbox" v-model="devFlags.showHud" />
<span>showHud</span>
</label>
</div>
</div>
<!-- Takeover -->
<div class="dev-section">
<h3>Takeover</h3>
<div class="dev-actions" v-if="!takeoverToken">
<button class="dev-theme-btn" @click="handleInitTakeover"><LinkIcon class="w-4 h-4 inline" /> Enable Takeover</button>
</div>
<div v-else class="dev-actions" style="gap: var(--space-page); align-items: center; flex-wrap: wrap;">
<code class="takeover-token">{{ takeoverToken }}</code>
<button class="dev-theme-btn active" @click="handleRevoke"><XMarkIcon class="w-4 h-4 inline" /> Revoke</button>
<button class="dev-theme-btn" :class="{ active: captureActive }" @click="handleToggleCapture">
<CameraIcon class="w-4 h-4 inline" /> {{ captureActive ? 'Capture ON' : 'Enable Capture' }}
</button>
</div>
</div>
<!-- System Access -->
<div class="dev-section" v-if="systemRequests.length > 0 || systemGranted">
<h3>System Access</h3>
<div v-if="systemGranted" class="dev-system-granted">
Access granted to {{ systemGranted.user }} active until session end.
</div>
<div v-for="req in systemRequests" :key="req.requestId" class="dev-system-request">
<div class="dev-system-code">{{ req.userCode }}</div>
<div class="dev-system-desc">{{ req.description }}</div>
<div class="dev-system-expiry">expires in {{ Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000)) }}s</div>
<div class="dev-actions" style="margin-top: 8px;">
<button class="dev-theme-btn" @click="denySystemRequest(req.requestId)">Deny</button>
<button class="dev-theme-btn active" @click="approveSystemRequest(req.requestId)">Approve</button>
</div>
</div>
</div>
<!-- MCP Counter -->
<div class="dev-section">
<h3>MCP Counter</h3>
<div class="counter-widget" :class="{ muted: counterMuted }">
<div class="counter-controls">
<button class="counter-btn" @click="counterAction('decrement')" :disabled="counterMuted || counterBusy"></button>
<span class="counter-value" id="mcp-counter-value">{{ counterValue }}</span>
<button class="counter-btn" @click="counterAction('increment')" :disabled="counterMuted || counterBusy">+</button>
</div>
<div v-if="challengeMessage" class="counter-challenge">
<span class="counter-message">{{ challengeMessage }}</span>
<span v-if="challengeTimer > 0" class="counter-timer">{{ challengeTimer }}s</span>
</div>
<div v-else class="counter-hint">Waiting for Claude...</div>
</div>
</div>
<!-- Action Picker -->
<div class="dev-section" v-if="actionPicker.title">
<h3>{{ actionPicker.title }}</h3>
<div class="action-picker">
<button
v-for="(opt, i) in actionPicker.options"
:key="i"
class="action-pick-btn"
:disabled="actionPickerBusy"
@click="pickAction(opt.id)"
>{{ opt.label }}</button>
</div>
</div>
<!-- Credits -->
<div class="dev-section">
<h3>OpenRouter Credits</h3>
<div v-if="loading && !stats" class="dev-loading">Loading</div>
<div v-else-if="statsError" class="dev-error">{{ statsError }}</div>
<div v-else-if="stats" class="credits-widget">
<div class="credits-bar-track">
<div class="credits-bar-fill" :style="{ width: usedPct + '%' }"></div>
</div>
<div class="credits-row">
<div class="credits-stat">
<span class="credits-label">Used</span>
<span class="credits-amount credits-used">${{ fmt(stats.credits.used) }}</span>
</div>
<div class="credits-stat">
<span class="credits-label">Remaining</span>
<span class="credits-amount credits-remaining">${{ fmt(stats.credits.remaining) }}</span>
</div>
<div class="credits-stat">
<span class="credits-label">Total</span>
<span class="credits-amount">${{ fmt(stats.credits.total) }}</span>
</div>
</div>
</div>
</div>
<!-- Agents table -->
<div class="dev-section" v-if="stats">
<h3>Agents</h3>
<div class="dev-table-wrap">
<table class="dev-table">
<thead>
<tr>
<th>Agent</th>
<th>Model</th>
<th>Context</th>
<th>Prompt / 1M</th>
<th>Completion / 1M</th>
</tr>
</thead>
<tbody>
<tr v-for="a in stats.agents" :key="a.id">
<td class="agent-id">{{ a.id }}</td>
<td><code>{{ a.modelId || a.model }}</code></td>
<td>{{ a.contextLength ? (a.contextLength / 1000).toFixed(0) + 'k' : '—' }}</td>
<td>{{ a.promptPrice !== null ? '$' + a.promptPrice.toFixed(3) : '—' }}</td>
<td>{{ a.completionPrice !== null ? '$' + a.completionPrice.toFixed(3) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Breakout confirmation modal (inside v-if="isLoggedIn") -->
<Teleport to="body">
<div v-if="breakoutReq" class="breakout-modal-overlay" @click.self="denyBreakout">
<div class="breakout-modal">
<h3>Open Breakout</h3>
<p>Name: <strong>{{ breakoutReq.name }}</strong></p>
<p>Size: <strong>{{ breakoutReq.preset }}</strong></p>
<p class="breakout-nonce">{{ breakoutReq.nonce }}</p>
<div class="breakout-modal-actions">
<button class="dev-theme-btn" @click="denyBreakout">Cancel</button>
<button class="dev-theme-btn active" @click="confirmBreakout">Confirm</button>
</div>
</div>
</div>
</Teleport>
</OverlayScrollbarsComponent>
<div v-else class="not-logged-in">
<p><LockClosedIcon class="w-5 h-5 inline" /> Not logged in</p>
<RouterLink to="/login">Sign in &rarr;</RouterLink>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'DevView' });
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { ws, auth, takeover } from '../store';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
import { scrollbarOptions } from '../composables/useScrollbar';
import { BoltIcon, SignalSlashIcon, LockClosedIcon, LinkIcon, XMarkIcon, CameraIcon, HomeIcon } from '@heroicons/vue/20/solid';
import { THEME_ICONS } from '../composables/useTheme';
import { useTheme } from '../composables/useTheme';
import { useDevFlags } from '../composables/useDevFlags';
import { getApiBase } from '../utils/apiBase';
const route = useRoute();
const { isLoggedIn } = auth;
const { connected, send: wsSend, onMessage } = ws;
const { theme, setTheme } = useTheme();
const devFlags = useDevFlags();
// Takeover (delegates to composables)
const takeoverToken = takeover.token;
const captureActive = takeover.capture.isActive;
const breakoutReq = takeover.breakout.pendingRequest;
function handleInitTakeover() { takeover.init(); }
function handleRevoke() { takeover.revoke(); }
// Breakout modal confirm/deny (for CLI-initiated openBreakout)
function confirmBreakout() { breakoutReq.value?.resolve(true); }
function denyBreakout() { breakoutReq.value?.resolve(false); }
// Capture toggle
async function handleToggleCapture() {
if (captureActive.value) {
takeover.capture.disable();
} else {
await takeover.capture.enable();
}
}
// System Access
const SESSION_KEY = 'nyx_session';
const SESSION_TOKEN = () => localStorage.getItem(SESSION_KEY) ?? '';
interface SystemRequest { requestId: string; userCode: string; description: string; expiresAt: number; }
const systemRequests = ref<SystemRequest[]>([]);
const systemGranted = ref<{ user: string } | null>(null);
async function fetchPendingSystemRequests() {
try {
const res = await fetch(`${getApiBase()}/api/system/pending`, {
headers: { Authorization: `Bearer ${SESSION_TOKEN()}` },
});
if (res.ok) {
const { pending } = await res.json();
systemRequests.value = pending ?? [];
}
} catch { /* silent */ }
}
async function approveSystemRequest(requestId: string) {
const res = await fetch(`${getApiBase()}/api/system/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken: SESSION_TOKEN(), requestId }),
});
if (res.ok) systemRequests.value = systemRequests.value.filter(r => r.requestId !== requestId);
}
async function denySystemRequest(requestId: string) {
await fetch(`${getApiBase()}/api/system/deny`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken: SESSION_TOKEN(), requestId }),
});
systemRequests.value = systemRequests.value.filter(r => r.requestId !== requestId);
}
// MCP Counter
const counterValue = ref(Math.floor(Math.random() * 11)); // 0..10
const counterBusy = ref(false);
const counterMuted = ref(true);
const challengeMessage = ref('');
const challengeTimer = ref(0);
let challengeInterval: ReturnType<typeof setInterval> | null = null;
let challengeGen = 0;
// Action Picker
const actionPicker = ref<{ title: string; options: { id: string; label: string }[] }>({ title: '', options: [] });
const actionPickerBusy = ref(false);
async function pickAction(id: string) {
actionPickerBusy.value = true;
actionPicker.value = { title: '', options: [] };
try {
await fetch(`${getApiBase()}/api/dev/counter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SESSION_TOKEN()}` },
body: JSON.stringify({ action: 'pick', pick: id }),
});
} catch { /* silent */ }
actionPickerBusy.value = false;
}
async function counterAction(action: 'increment' | 'decrement') {
counterMuted.value = true;
challengeGen++;
if (challengeInterval) { clearInterval(challengeInterval); challengeInterval = null; }
challengeMessage.value = '';
challengeTimer.value = 0;
counterBusy.value = true;
try {
await fetch(`${getApiBase()}/api/dev/counter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SESSION_TOKEN()}` },
body: JSON.stringify({ action }),
});
} catch { /* silent */ }
counterBusy.value = false;
}
// Stats
const loading = ref(false);
const statsError = ref('');
const stats = ref<any>(null);
function fmt(v: number | null) {
if (v === null || v === undefined) return '—';
return v.toFixed(2);
}
const usedPct = computed(() => {
const t = stats.value?.credits?.total;
const u = stats.value?.credits?.used;
if (!t) return 0;
return Math.min(100, (u / t) * 100);
});
function load() {
if (!connected.value) return;
loading.value = true;
statsError.value = '';
wsSend({ type: 'stats_request' });
}
const discoing = ref(false);
const discoChatting = ref(false);
function disco() {
if (!connected.value) return;
discoing.value = true;
wsSend({ type: 'disco_request' });
setTimeout(() => { discoing.value = false; }, 2000);
}
function discoChat() {
if (!connected.value) return;
discoChatting.value = true;
wsSend({ type: 'disco_chat_request' });
setTimeout(() => { discoChatting.value = false; }, 3000);
}
// Polling lifecycle
let unsubscribeWs: (() => void) | null = null;
let refreshInterval: number | undefined;
onMounted(() => {
fetchPendingSystemRequests();
unsubscribeWs = onMessage((data: any) => {
if (data.type === 'system_access_request') {
if (!systemRequests.value.find(r => r.requestId === data.requestId))
systemRequests.value.push(data);
return;
}
if (data.type === 'counter_update') {
counterValue.value = data.value ?? counterValue.value;
return;
}
if (data.type === 'counter_challenge') {
challengeMessage.value = data.message || 'DECIDE NOW!';
challengeTimer.value = data.timeout || 30;
counterMuted.value = false;
// Flash + scroll into view
nextTick(() => {
const el = document.querySelector('.counter-widget');
if (el) {
el.classList.remove('flash');
void (el as HTMLElement).offsetWidth; // reflow
el.classList.add('flash');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
if (challengeInterval) clearInterval(challengeInterval);
const cid = ++challengeGen;
challengeInterval = setInterval(() => {
if (cid !== challengeGen) { clearInterval(challengeInterval!); return; }
challengeTimer.value--;
if (challengeTimer.value <= 0) {
if (challengeInterval) { clearInterval(challengeInterval); challengeInterval = null; }
counterMuted.value = true;
challengeMessage.value = '';
fetch(`${getApiBase()}/api/dev/counter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SESSION_TOKEN()}` },
body: JSON.stringify({ action: 'timeout' }),
});
}
}, 1000);
return;
}
if (data.type === 'confetti') {
const container = document.createElement('div');
container.className = 'confetti-container';
const colors = ['#ff6b6b','#ffd93d','#6bcb77','#4d96ff','#ff922b','#cc5de8'];
for (let i = 0; i < 60; i++) {
const piece = document.createElement('div');
piece.className = 'confetti-piece';
piece.style.left = Math.random() * 100 + '%';
piece.style.background = colors[Math.floor(Math.random() * colors.length)];
piece.style.animationDelay = Math.random() * 2 + 's';
piece.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
container.appendChild(piece);
}
document.body.appendChild(container);
setTimeout(() => container.remove(), 5000);
return;
}
if (data.type === 'action_picker') {
actionPicker.value = { title: data.title || 'Next?', options: data.options || [] };
actionPickerBusy.value = false;
return;
}
if (data.type === 'counter_mute') {
counterMuted.value = true;
challengeMessage.value = data.message || '';
if (challengeInterval) { clearInterval(challengeInterval); challengeInterval = null; }
challengeTimer.value = 0;
return;
}
if (data.type === 'stats') {
loading.value = false;
if (data.error) { statsError.value = data.error; }
else { stats.value = data; }
}
});
const startRefresh = () => {
load();
refreshInterval = setInterval(load, 15000) as unknown as number;
};
if (connected.value) {
startRefresh();
} else {
const stop = watch(connected, (val) => {
if (val) { stop(); startRefresh(); }
});
}
});
watch(() => route.name, (name, prev) => {
if (name === 'dev' && !refreshInterval && connected.value) {
load();
refreshInterval = setInterval(load, 15000) as unknown as number;
} else if (prev === 'dev' && name !== 'dev' && refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = undefined;
}
});
onUnmounted(() => {
if (unsubscribeWs) unsubscribeWs();
if (refreshInterval) clearInterval(refreshInterval);
});
</script>
<style scoped>
.dev-view {
height: 100%;
}
.dev-system-request {
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 8px;
}
.dev-system-code {
font-family: monospace;
font-size: 1.4em;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--color-accent);
margin-bottom: 4px;
}
.dev-system-desc {
font-size: 0.9em;
color: var(--color-text);
margin-bottom: 2px;
}
.dev-system-expiry {
font-size: 0.8em;
color: var(--color-text-dim);
margin-bottom: 4px;
}
.dev-system-granted {
font-size: 0.9em;
color: var(--color-success-dim, #4ade80);
padding: 6px 0;
}
</style>