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 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
22
packages/web/src/components/LanguageSwitcher.vue
Normal file
22
packages/web/src/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<select
|
||||
:value="locale"
|
||||
:aria-label="t('common.language')"
|
||||
class="rounded-md border border-zinc-700 bg-zinc-900 px-1.5 py-1 text-xs text-zinc-300 outline-none focus:border-zinc-400"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="en">EN</option>
|
||||
<option value="fr">FR</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { setLocale, type AppLocale } from '../i18n';
|
||||
|
||||
const { locale, t } = useI18n({ useScope: 'global' });
|
||||
|
||||
function onChange(e: Event): void {
|
||||
setLocale((e.target as HTMLSelectElement).value as AppLocale);
|
||||
}
|
||||
</script>
|
||||
118
packages/web/src/components/TerminalView.vue
Normal file
118
packages/web/src/components/TerminalView.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="relative min-h-0 bg-[#09090b]">
|
||||
<div ref="container" class="h-full w-full" />
|
||||
<div
|
||||
v-if="!controlling && !ended"
|
||||
class="pointer-events-none absolute top-2 right-4 rounded bg-zinc-800/90 px-2 py-0.5 text-xs text-amber-300"
|
||||
>
|
||||
{{ t('terminal.observer') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="ended"
|
||||
class="absolute inset-x-0 top-0 bg-red-950/90 px-4 py-2 text-center text-sm text-red-200"
|
||||
>
|
||||
{{ t('terminal.sessionEnded') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
||||
// si controlling), flow control et resync entièrement délégués au ws-client.
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { wsClient, type Attachment } from '../lib/ws-client';
|
||||
|
||||
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
||||
mode: 'interactive',
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const container = useTemplateRef<HTMLDivElement>('container');
|
||||
const controlling = ref(true);
|
||||
const ended = ref(false);
|
||||
|
||||
let term: Terminal | null = null;
|
||||
let attachment: Attachment | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let disposed = false;
|
||||
|
||||
onMounted(async () => {
|
||||
if (!container.value) return;
|
||||
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: '#09090b',
|
||||
foreground: '#d4d4d8',
|
||||
cursor: '#e4e4e7',
|
||||
selectionBackground: '#3f3f46',
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(container.value);
|
||||
|
||||
try {
|
||||
const { WebglAddon } = await import('@xterm/addon-webgl');
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
term.loadAddon(webgl);
|
||||
} catch {
|
||||
// WebGL indisponible : xterm retombe sur le renderer DOM
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
const activeTerm = term;
|
||||
|
||||
try {
|
||||
attachment = await wsClient.attach({
|
||||
sessionId: props.sessionId,
|
||||
mode: props.mode,
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
sink: {
|
||||
write: (data, callback) => activeTerm.write(data, callback),
|
||||
reset: () => activeTerm.reset(),
|
||||
onDetached: (reason) => {
|
||||
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
||||
},
|
||||
onControlChanged: (c) => {
|
||||
controlling.value = c;
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// session introuvable ou déjà terminée
|
||||
ended.value = true;
|
||||
controlling.value = false;
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
attachment.detach();
|
||||
return;
|
||||
}
|
||||
controlling.value = attachment.controlling;
|
||||
|
||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
||||
});
|
||||
resizeObserver.observe(container.value);
|
||||
if (props.mode === 'interactive') activeTerm.focus();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
attachment?.detach();
|
||||
term?.dispose();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user