P1 complete: web front, test suite, CI — acceptance ALL GREEN
Fan-out integration + fixes found by the test/acceptance pass: - FIX ring-buffer: chunks >= capacity skipped bytes now count into the monotonic offset (invariant: stream byte k lives at k % capacity) — window order was corrupted on unaligned big chunks - FIX auth: non-numeric cookie expiry no longer bypasses expiration - FIX protocol: safe-integer validation on ack.bytes / hello.protocol - FIX @fastify/websocket v11: websocket route must be registered in an encapsulated context after plugin load (handler got REST signature) - FIX flow-control deadlock found by e2e acceptance: client only ACKs on data receipt, so pausing with an unACKed residue in (LOW, ACK_EVERY] stalled both sides at 0.9 MB. ACK_EVERY now 64 KiB (<= LOW invariant, tested) + trailing debounced ACK in the web client - Web: Vue 3 + Vite + Pinia + Tailwind 4 + vue-i18n (EN/FR) + xterm 6 (fit + webgl fallback), multiplexed ws-client with reconnect/backoff and resync epochs - Tests: 100 vitest (protocol fuzz, ring edges, auth, pty-manager flow control with mocked pty, REST e2e) ; CI Node 22/24 + pack-smoke - scripts/acceptance-p1.mjs: real daemon + real WS client — boot, login, attach, stdin, 10 MB flood w/ ACK (13.7 MB/1.9s, RSS bounded), brutal disconnect + replay resync, kill broadcast, SIGTERM drain
This commit is contained in:
64
packages/web/src/views/LoginView.vue
Normal file
64
packages/web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center px-4">
|
||||
<form
|
||||
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900/60 p-6"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('common.appName') }}</h1>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400">{{ t('login.title') }}</p>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('login.tokenLabel') }}
|
||||
<input
|
||||
v-model="token"
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
:placeholder="t('login.tokenPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
<button type="submit" class="btn-primary" :disabled="submitting || token.trim() === ''">
|
||||
{{ submitting ? t('login.submitting') : t('login.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const token = ref('');
|
||||
const error = ref<string | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await auth.login(token.value.trim());
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
||||
await router.replace(redirect);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) error.value = t('login.invalidToken');
|
||||
else if (err instanceof ApiError && err.status === 429) error.value = t('login.rateLimited');
|
||||
else error.value = t('login.genericError', { status: err instanceof ApiError ? err.status : '?' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
40
packages/web/src/views/SessionView.vue
Normal file
40
packages/web/src/views/SessionView.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
|
||||
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
||||
← {{ t('terminal.back') }}
|
||||
</RouterLink>
|
||||
<span v-if="session" class="badge bg-zinc-800 font-mono text-zinc-300">{{ session.command }}</span>
|
||||
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
||||
{{ session?.cwd ?? sessionId }}
|
||||
</span>
|
||||
<div class="ml-auto">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
<TerminalView :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import TerminalView from '../components/TerminalView.vue';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useSessionsStore();
|
||||
|
||||
const sessionId = computed(() => String(route.params.id));
|
||||
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
||||
const session = computed(() => store.sessions.find((s) => s.id === sessionId.value) ?? null);
|
||||
|
||||
onMounted(() => {
|
||||
// navigation directe : la liste n'est pas encore chargée (en-tête cwd/commande)
|
||||
if (store.sessions.length === 0) void store.fetchSessions();
|
||||
store.startRealtime();
|
||||
});
|
||||
</script>
|
||||
151
packages/web/src/views/SessionsListView.vue
Normal file
151
packages/web/src/views/SessionsListView.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||
<header class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.title') }}</h1>
|
||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
|
||||
{{ t('sessions.refresh') }}
|
||||
</button>
|
||||
<LanguageSwitcher />
|
||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end"
|
||||
@submit.prevent="onCreate"
|
||||
>
|
||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('sessions.cwdLabel') }}
|
||||
<input
|
||||
v-model="newCwd"
|
||||
type="text"
|
||||
class="input font-mono"
|
||||
:placeholder="t('sessions.cwdPlaceholder')"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('sessions.commandLabel') }}
|
||||
<select v-model="newCommand" class="input">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="creating || newCwd.trim() === ''">
|
||||
{{ creating ? t('sessions.creating') : t('sessions.newSession') }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="createError" class="text-sm text-red-400">{{ createError }}</p>
|
||||
|
||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
||||
<p v-else-if="store.loading && store.sessions.length === 0" class="text-sm text-zinc-500">
|
||||
{{ t('common.loading') }}
|
||||
</p>
|
||||
<p v-else-if="store.sessions.length === 0" class="text-sm text-zinc-500">{{ t('sessions.empty') }}</p>
|
||||
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="s in store.sessions"
|
||||
:key="s.id"
|
||||
class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
||||
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
||||
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||
{{ t('sessions.live') }}
|
||||
</span>
|
||||
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="truncate font-mono text-sm text-zinc-300" :title="s.cwd">{{ s.cwd }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{{ t(`sessions.statusLabel.${s.status}`) }}</span>
|
||||
<span v-if="s.live">· {{ t('sessions.clients', s.clients) }}</span>
|
||||
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
||||
<div v-if="s.live" class="ml-auto flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">
|
||||
{{ t('sessions.open') }}
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }" class="btn">
|
||||
{{ t('sessions.observe') }}
|
||||
</RouterLink>
|
||||
<template v-if="killArmedId === s.id">
|
||||
<button class="btn-danger" :disabled="killing" @click="onConfirmKill(s.id)">
|
||||
{{ t('sessions.confirmKill') }}
|
||||
</button>
|
||||
<button class="btn" @click="killArmedId = null">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn-danger" @click="killArmedId = s.id">{{ t('sessions.kill') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const store = useSessionsStore();
|
||||
|
||||
const newCwd = ref('');
|
||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||
const creating = ref(false);
|
||||
const createError = ref<string | null>(null);
|
||||
const killArmedId = ref<string | null>(null);
|
||||
const killing = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
void store.fetchSessions();
|
||||
store.startRealtime();
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value);
|
||||
}
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
creating.value = true;
|
||||
createError.value = null;
|
||||
try {
|
||||
const session = await store.createSession(newCwd.value.trim(), newCommand.value);
|
||||
newCwd.value = '';
|
||||
await router.push({ name: 'session', params: { id: session.id } });
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmKill(id: string): Promise<void> {
|
||||
killing.value = true;
|
||||
try {
|
||||
await store.killSession(id);
|
||||
} catch {
|
||||
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
|
||||
} finally {
|
||||
killing.value = false;
|
||||
killArmedId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
store.stopRealtime();
|
||||
await auth.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user