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:
13
packages/web/index.html
Normal file
13
packages/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: #09090b">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Arboretum</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
packages/web/package.json
Normal file
29
packages/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@arboretum/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arboretum/shared": "*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.5",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.4"
|
||||
}
|
||||
}
|
||||
20
packages/web/src/App.vue
Normal file
20
packages/web/src/App.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="wsReconnecting"
|
||||
class="border-b border-amber-900/50 bg-amber-950/80 px-4 py-1.5 text-center text-xs text-amber-300"
|
||||
>
|
||||
{{ t('ws.reconnecting') }}
|
||||
</div>
|
||||
<RouterView class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { wsClient } from './lib/ws-client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
||||
</script>
|
||||
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>
|
||||
49
packages/web/src/i18n/en.ts
Normal file
49
packages/web/src/i18n/en.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: 'Arboretum',
|
||||
loading: 'Loading…',
|
||||
cancel: 'Cancel',
|
||||
logout: 'Log out',
|
||||
language: 'Language',
|
||||
},
|
||||
login: {
|
||||
title: 'Sign in with your access token',
|
||||
tokenLabel: 'Access token',
|
||||
tokenPlaceholder: 'arb_…',
|
||||
submit: 'Sign in',
|
||||
submitting: 'Signing in…',
|
||||
invalidToken: 'Invalid token',
|
||||
rateLimited: 'Too many attempts — please wait before retrying',
|
||||
genericError: 'Sign-in failed ({status})',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Sessions',
|
||||
refresh: 'Refresh',
|
||||
empty: 'No sessions yet — start one below.',
|
||||
newSession: 'New session',
|
||||
cwdLabel: 'Working directory',
|
||||
cwdPlaceholder: '/absolute/path/to/project',
|
||||
commandLabel: 'Command',
|
||||
creating: 'Creating…',
|
||||
open: 'Open',
|
||||
observe: 'Observe',
|
||||
kill: 'Kill',
|
||||
confirmKill: 'Confirm kill',
|
||||
live: 'live',
|
||||
clients: 'no client | 1 client | {n} clients',
|
||||
exitCode: 'exit code {code}',
|
||||
statusLabel: {
|
||||
starting: 'starting',
|
||||
running: 'running',
|
||||
exited: 'exited',
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observer (read-only)',
|
||||
sessionEnded: 'Session ended',
|
||||
back: 'Sessions',
|
||||
},
|
||||
ws: {
|
||||
reconnecting: 'Connection lost — reconnecting…',
|
||||
},
|
||||
};
|
||||
53
packages/web/src/i18n/fr.ts
Normal file
53
packages/web/src/i18n/fr.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type en from './en';
|
||||
|
||||
const fr: typeof en = {
|
||||
common: {
|
||||
appName: 'Arboretum',
|
||||
loading: 'Chargement…',
|
||||
cancel: 'Annuler',
|
||||
logout: 'Se déconnecter',
|
||||
language: 'Langue',
|
||||
},
|
||||
login: {
|
||||
title: 'Connectez-vous avec votre jeton d’accès',
|
||||
tokenLabel: 'Jeton d’accès',
|
||||
tokenPlaceholder: 'arb_…',
|
||||
submit: 'Se connecter',
|
||||
submitting: 'Connexion…',
|
||||
invalidToken: 'Jeton invalide',
|
||||
rateLimited: 'Trop de tentatives — patientez avant de réessayer',
|
||||
genericError: 'Échec de la connexion ({status})',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Sessions',
|
||||
refresh: 'Rafraîchir',
|
||||
empty: 'Aucune session — démarrez-en une ci-dessous.',
|
||||
newSession: 'Nouvelle session',
|
||||
cwdLabel: 'Répertoire de travail',
|
||||
cwdPlaceholder: '/chemin/absolu/du/projet',
|
||||
commandLabel: 'Commande',
|
||||
creating: 'Création…',
|
||||
open: 'Ouvrir',
|
||||
observe: 'Observer',
|
||||
kill: 'Tuer',
|
||||
confirmKill: 'Confirmer',
|
||||
live: 'en cours',
|
||||
clients: 'aucun client | 1 client | {n} clients',
|
||||
exitCode: 'code de sortie {code}',
|
||||
statusLabel: {
|
||||
starting: 'démarrage',
|
||||
running: 'en cours',
|
||||
exited: 'terminée',
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observateur (lecture seule)',
|
||||
sessionEnded: 'Session terminée',
|
||||
back: 'Sessions',
|
||||
},
|
||||
ws: {
|
||||
reconnecting: 'Connexion perdue — reconnexion…',
|
||||
},
|
||||
};
|
||||
|
||||
export default fr;
|
||||
24
packages/web/src/i18n/index.ts
Normal file
24
packages/web/src/i18n/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en';
|
||||
import fr from './fr';
|
||||
|
||||
export type AppLocale = 'en' | 'fr';
|
||||
|
||||
const STORAGE_KEY = 'arboretum.locale';
|
||||
|
||||
function initialLocale(): AppLocale {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'fr' || saved === 'en' ? saved : 'en';
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, fr },
|
||||
});
|
||||
|
||||
export function setLocale(locale: AppLocale): void {
|
||||
i18n.global.locale.value = locale;
|
||||
localStorage.setItem(STORAGE_KEY, locale);
|
||||
}
|
||||
38
packages/web/src/lib/api.ts
Normal file
38
packages/web/src/lib/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Mini client REST : JSON, cookies de session, erreurs API normalisées.
|
||||
import type { ApiError as ApiErrorBody } from '@arboretum/shared';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = { method, credentials: 'same-origin' };
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'content-type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, init);
|
||||
if (!res.ok) {
|
||||
let parsed: ApiErrorBody | null = null;
|
||||
try {
|
||||
parsed = (await res.json()) as ApiErrorBody;
|
||||
} catch {
|
||||
// réponse non-JSON : on retombe sur le statusText
|
||||
}
|
||||
throw new ApiError(res.status, parsed?.error.code ?? 'UNKNOWN', parsed?.error.message ?? res.statusText);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
||||
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
||||
};
|
||||
370
packages/web/src/lib/ws-client.ts
Normal file
370
packages/web/src/lib/ws-client.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Client WebSocket multiplexé — UNE seule connexion pour toute l'app.
|
||||
// Handshake hello/hello_ok, reconnexion avec backoff (ré-attache des terminaux
|
||||
// ouverts + re-sub), corrélation FIFO des 'attached', flow control par ACK
|
||||
// (cumul d'octets traités PAR CANAL, remis à zéro à chaque RESYNC).
|
||||
import { ref, type Ref } from 'vue';
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
FLOW,
|
||||
PROTOCOL_VERSION,
|
||||
decodeBinaryFrame,
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
} from '@arboretum/shared';
|
||||
|
||||
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
|
||||
|
||||
export type DetachReason = 'client' | 'session_exit' | 'replaced';
|
||||
|
||||
/** Interface minimale attendue côté terminal (implémentée par xterm). */
|
||||
export interface TerminalSink {
|
||||
/** le callback est invoqué quand xterm a réellement traité le chunk (base du flow control) */
|
||||
write(data: Uint8Array, callback?: () => void): void;
|
||||
/** reset complet avant le replay d'une frame RESYNC */
|
||||
reset(): void;
|
||||
onDetached(reason: DetachReason): void;
|
||||
onControlChanged(controlling: boolean): void;
|
||||
}
|
||||
|
||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||
|
||||
export interface AttachOptions {
|
||||
sessionId: string;
|
||||
mode: 'interactive' | 'observer';
|
||||
cols: number;
|
||||
rows: number;
|
||||
sink: TerminalSink;
|
||||
}
|
||||
|
||||
const BACKOFF_MIN_MS = 500;
|
||||
const BACKOFF_MAX_MS = 10_000;
|
||||
|
||||
export class Attachment {
|
||||
/** canal courant — réassigné à chaque reconnexion, -1 tant que non attaché */
|
||||
channel = -1;
|
||||
controlling = false;
|
||||
closed = false;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** époque de resync : invalide les callbacks d'écritures encore en vol après un reset */
|
||||
epoch = 0;
|
||||
/** octets de payload réellement traités par le terminal depuis le dernier resync */
|
||||
processedBytes = 0;
|
||||
/** dernier cumul envoyé en ack (repart à 0 à chaque resync, comme côté serveur) */
|
||||
lastAckBytes = 0;
|
||||
/** ACK traînant : garantit l'envoi du reliquat même quand le flux s'arrête (anti-deadlock) */
|
||||
trailingAckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** résolution du premier 'attached' de ce canal (corrélation FIFO) */
|
||||
pending: { resolve: (a: Attachment) => void; reject: (err: Error) => void } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly client: WsClient,
|
||||
readonly sessionId: string,
|
||||
readonly mode: 'interactive' | 'observer',
|
||||
readonly sink: TerminalSink,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
sendStdin(data: string): void {
|
||||
if (this.closed || this.channel < 0) return;
|
||||
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
|
||||
}
|
||||
|
||||
/** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */
|
||||
resize(cols: number, rows: number): void {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
if (this.closed || this.channel < 0 || !this.controlling) return;
|
||||
this.client.sendControl({ type: 'resize', channel: this.channel, cols, rows });
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.pending = null;
|
||||
this.client.releaseAttachment(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class WsClient {
|
||||
readonly status: Ref<WsStatus> = ref('idle');
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private ready = false;
|
||||
private stopped = true;
|
||||
private backoffMs = BACKOFF_MIN_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
|
||||
private readonly attachments = new Set<Attachment>();
|
||||
private readonly byChannel = new Map<number, Attachment>();
|
||||
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
|
||||
private awaitingAttached: Attachment[] = [];
|
||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
if (this.socket || this.reconnectTimer) return;
|
||||
if (this.status.value === 'idle') this.status.value = 'connecting';
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopped = true;
|
||||
this.clearReconnectTimer();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.ready = false;
|
||||
this.status.value = 'idle';
|
||||
for (const att of this.attachments) {
|
||||
att.closed = true;
|
||||
att.pending?.reject(new Error('disconnected'));
|
||||
att.pending = null;
|
||||
att.sink.onDetached('client');
|
||||
}
|
||||
this.attachments.clear();
|
||||
this.byChannel.clear();
|
||||
this.awaitingAttached = [];
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
||||
attach(opts: AttachOptions): Promise<Attachment> {
|
||||
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows);
|
||||
const promise = new Promise<Attachment>((resolve, reject) => {
|
||||
att.pending = { resolve, reject };
|
||||
});
|
||||
this.attachments.add(att);
|
||||
this.connect();
|
||||
if (this.ready) this.sendAttach(att);
|
||||
return promise;
|
||||
}
|
||||
|
||||
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
|
||||
this.sessionListeners.add(listener);
|
||||
this.connect();
|
||||
if (this.sessionListeners.size === 1) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
||||
return () => {
|
||||
this.sessionListeners.delete(listener);
|
||||
if (this.sessionListeners.size === 0) this.sendControl({ type: 'sub', topics: [] });
|
||||
};
|
||||
}
|
||||
|
||||
sendControl(msg: ClientMessage): void {
|
||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
/** détache côté serveur et oublie l'attache (appelé par Attachment.detach) */
|
||||
releaseAttachment(att: Attachment): void {
|
||||
this.attachments.delete(att);
|
||||
if (att.channel >= 0) {
|
||||
this.byChannel.delete(att.channel);
|
||||
this.sendControl({ type: 'detach', channel: att.channel });
|
||||
att.channel = -1;
|
||||
}
|
||||
// si l'attache attend encore son 'attached', elle reste dans la FIFO pour
|
||||
// préserver la corrélation : closed=true la fera détacher à la réception
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
|
||||
private openSocket(): void {
|
||||
const socket = new WebSocket(`${location.origin.replace(/^http/, 'ws')}/ws`);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
const hello: ClientMessage = { type: 'hello', protocol: PROTOCOL_VERSION };
|
||||
socket.send(JSON.stringify(hello));
|
||||
};
|
||||
socket.onmessage = (ev: MessageEvent) => this.handleMessage(ev);
|
||||
socket.onclose = () => this.handleClose(socket);
|
||||
socket.onerror = () => {
|
||||
// onclose suit systématiquement : la reconnexion se joue là-bas
|
||||
};
|
||||
}
|
||||
|
||||
private handleClose(socket: WebSocket): void {
|
||||
if (this.socket !== socket) return;
|
||||
this.socket = null;
|
||||
this.ready = false;
|
||||
this.byChannel.clear();
|
||||
this.awaitingAttached = [];
|
||||
for (const att of this.attachments) {
|
||||
att.channel = -1;
|
||||
att.epoch += 1;
|
||||
}
|
||||
if (this.stopped) {
|
||||
this.status.value = 'idle';
|
||||
return;
|
||||
}
|
||||
this.status.value = 'reconnecting';
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (!this.stopped) this.openSocket();
|
||||
}, this.backoffMs);
|
||||
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sendAttach(att: Attachment): void {
|
||||
this.awaitingAttached.push(att);
|
||||
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows });
|
||||
}
|
||||
|
||||
private handleMessage(ev: MessageEvent): void {
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
this.handleBinary(new Uint8Array(ev.data));
|
||||
return;
|
||||
}
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as ServerMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handleServerMessage(msg);
|
||||
}
|
||||
|
||||
private handleBinary(data: Uint8Array): void {
|
||||
if (data.byteLength < BINARY_FRAME.HEADER_BYTES) return;
|
||||
const frame = decodeBinaryFrame(data);
|
||||
const att = this.byChannel.get(frame.channel);
|
||||
if (!att || att.closed) return;
|
||||
|
||||
if (frame.type === BINARY_FRAME.RESYNC) {
|
||||
// le serveur repart de sentBytes=0 après un resync : reset terminal, replay
|
||||
// du ring, et compteurs d'octets remis à zéro (le payload du replay ne compte pas)
|
||||
att.epoch += 1;
|
||||
att.processedBytes = 0;
|
||||
att.lastAckBytes = 0;
|
||||
att.sink.reset();
|
||||
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
|
||||
return;
|
||||
}
|
||||
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
||||
|
||||
const epoch = att.epoch;
|
||||
const length = frame.payload.byteLength;
|
||||
att.sink.write(frame.payload, () => {
|
||||
// compté seulement si aucun resync/reconnexion n'est passé entre temps
|
||||
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
||||
att.processedBytes += length;
|
||||
if (att.processedBytes - att.lastAckBytes >= FLOW.ACK_EVERY_BYTES) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
// ACK traînant débouncé : si le flux s'arrête avec un reliquat non-ACKé,
|
||||
// le serveur ne doit jamais rester en pause faute d'ACK suivant.
|
||||
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = setTimeout(() => {
|
||||
att.trailingAckTimer = null;
|
||||
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
||||
if (att.processedBytes > att.lastAckBytes) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: ServerMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'hello_ok': {
|
||||
this.ready = true;
|
||||
this.backoffMs = BACKOFF_MIN_MS;
|
||||
this.status.value = 'open';
|
||||
if (this.sessionListeners.size > 0) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
||||
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
|
||||
for (const att of this.attachments) {
|
||||
if (!att.closed) this.sendAttach(att);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'attached': {
|
||||
const att = this.awaitingAttached.shift();
|
||||
if (!att) return;
|
||||
if (att.closed) {
|
||||
// détaché pendant l'attente : on libère le canal côté serveur
|
||||
this.sendControl({ type: 'detach', channel: msg.channel });
|
||||
return;
|
||||
}
|
||||
const wasControlling = att.controlling;
|
||||
att.channel = msg.channel;
|
||||
att.controlling = msg.controlling;
|
||||
this.byChannel.set(msg.channel, att);
|
||||
if (att.pending) {
|
||||
att.pending.resolve(att);
|
||||
att.pending = null;
|
||||
} else if (wasControlling !== msg.controlling) {
|
||||
// ré-attache après reconnexion : le statut controlling peut avoir changé
|
||||
att.sink.onControlChanged(msg.controlling);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'detached': {
|
||||
const att = this.byChannel.get(msg.channel);
|
||||
if (!att) return;
|
||||
this.byChannel.delete(msg.channel);
|
||||
att.channel = -1;
|
||||
if (msg.reason === 'client') return;
|
||||
att.closed = true;
|
||||
this.attachments.delete(att);
|
||||
att.sink.onDetached(msg.reason);
|
||||
return;
|
||||
}
|
||||
case 'control_changed': {
|
||||
const att = this.byChannel.get(msg.channel);
|
||||
if (!att || att.closed) return;
|
||||
att.controlling = msg.controlling;
|
||||
att.sink.onControlChanged(msg.controlling);
|
||||
return;
|
||||
}
|
||||
case 'session_update':
|
||||
case 'session_exit': {
|
||||
for (const cb of this.sessionListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
if (msg.channel !== undefined) {
|
||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
// un échec d'attach est la seule erreur sans canal corrélable à une requête
|
||||
if ((msg.code === 'NOT_FOUND' || msg.code === 'SESSION_EXITED') && this.awaitingAttached.length > 0) {
|
||||
const att = this.awaitingAttached.shift();
|
||||
if (!att) return;
|
||||
this.attachments.delete(att);
|
||||
if (att.pending) {
|
||||
att.pending.reject(new Error(msg.code));
|
||||
att.pending = null;
|
||||
} else if (!att.closed) {
|
||||
att.closed = true;
|
||||
att.sink.onDetached('session_exit');
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.warn(`[ws] ${msg.code} — ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
case 'pong':
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WsClient();
|
||||
8
packages/web/src/main.ts
Normal file
8
packages/web/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import { i18n } from './i18n';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
||||
20
packages/web/src/router/index.ts
Normal file
20
packages/web/src/router/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
|
||||
{ path: '/', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
});
|
||||
|
||||
// Garde globale : tout sauf /login exige une session valide (GET /api/v1/auth/me, 401 → /login)
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.authenticated === null) await auth.check();
|
||||
if (to.name === 'login') return auth.authenticated ? { name: 'sessions' } : true;
|
||||
return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
|
||||
});
|
||||
42
packages/web/src/stores/auth.ts
Normal file
42
packages/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import { api } from '../lib/api';
|
||||
import { wsClient } from '../lib/ws-client';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
/** null = pas encore vérifié auprès du serveur */
|
||||
const authenticated = ref<boolean | null>(null);
|
||||
const tokenLabel = ref<string | null>(null);
|
||||
const serverVersion = ref<string | null>(null);
|
||||
|
||||
async function check(): Promise<boolean> {
|
||||
try {
|
||||
const me = await api.get<MeResponse>('/api/v1/auth/me');
|
||||
tokenLabel.value = me.tokenLabel;
|
||||
serverVersion.value = me.serverVersion;
|
||||
authenticated.value = true;
|
||||
} catch {
|
||||
authenticated.value = false;
|
||||
}
|
||||
return authenticated.value === true;
|
||||
}
|
||||
|
||||
async function login(token: string): Promise<void> {
|
||||
const res = await api.post<LoginResponse>('/api/v1/auth/login', { token });
|
||||
tokenLabel.value = res.label;
|
||||
authenticated.value = true;
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.post<{ ok: true }>('/api/v1/auth/logout');
|
||||
} finally {
|
||||
wsClient.disconnect();
|
||||
authenticated.value = false;
|
||||
tokenLabel.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated, tokenLabel, serverVersion, check, login, logout };
|
||||
});
|
||||
76
packages/web/src/stores/sessions.ts
Normal file
76
packages/web/src/stores/sessions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { CreateSessionRequest, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../lib/api';
|
||||
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
||||
|
||||
export const useSessionsStore = defineStore('sessions', () => {
|
||||
const sessions = ref<SessionSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
||||
function sorted(list: SessionSummary[]): SessionSummary[] {
|
||||
return [...list].sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
function upsert(session: SessionSummary): void {
|
||||
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
||||
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
||||
else sessions.value.push(session);
|
||||
sessions.value = sorted(sessions.value);
|
||||
}
|
||||
|
||||
function onEvent(e: SessionEvent): void {
|
||||
if (e.type === 'session_update') {
|
||||
upsert(e.session);
|
||||
return;
|
||||
}
|
||||
const current = sessions.value.find((s) => s.id === e.sessionId);
|
||||
if (!current) return;
|
||||
upsert({
|
||||
...current,
|
||||
status: 'exited',
|
||||
live: false,
|
||||
exitCode: e.exitCode,
|
||||
endedAt: new Date().toISOString(),
|
||||
clients: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSessions(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const res = await api.get<SessionsListResponse>('/api/v1/sessions');
|
||||
sessions.value = sorted(res.sessions);
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRealtime(): void {
|
||||
unsubscribe ??= wsClient.subscribeSessions(onEvent);
|
||||
}
|
||||
|
||||
function stopRealtime(): void {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
async function createSession(cwd: string, command: NonNullable<CreateSessionRequest['command']>): Promise<SessionSummary> {
|
||||
const body: CreateSessionRequest = { cwd, command };
|
||||
const res = await api.post<SessionResponse>('/api/v1/sessions', body);
|
||||
upsert(res.session);
|
||||
return res.session;
|
||||
}
|
||||
|
||||
async function killSession(id: string): Promise<void> {
|
||||
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
||||
}
|
||||
|
||||
return { sessions, loading, loadError, fetchSessions, startRealtime, stopRealtime, createSession, killSession };
|
||||
});
|
||||
31
packages/web/src/style.css
Normal file
31
packages/web/src/style.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-zinc-950);
|
||||
color: var(--color-zinc-200);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input {
|
||||
@apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-400;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-red-900 bg-red-950 px-3 py-1.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium;
|
||||
}
|
||||
}
|
||||
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>
|
||||
16
packages/web/tsconfig.json
Normal file
16
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
29
packages/web/vite.config.ts
Normal file
29
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
const DAEMON_HTTP = 'http://127.0.0.1:7317';
|
||||
const DAEMON_WS = 'ws://127.0.0.1:7317';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
// L'Origin est réécrite vers celle du daemon : son check Origin strict
|
||||
// rejetterait sinon l'origine du serveur de dev Vite.
|
||||
'/api': {
|
||||
target: DAEMON_HTTP,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => proxyReq.setHeader('origin', DAEMON_HTTP));
|
||||
},
|
||||
},
|
||||
'/ws': {
|
||||
target: DAEMON_WS,
|
||||
ws: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReqWs', (proxyReq) => proxyReq.setHeader('origin', DAEMON_HTTP));
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user