Fix dependencies, SSL config, terminal UI, WebSocket client
- Fix @nestjs/typeorm version (^11.0.0), add socket.io - Add SSL CA cert support for Timeweb Cloud PostgreSQL - Add terminal component (xterm.js) with WebSocket streaming - Add session detail page with live terminal view - Add socket.io client utilities for real-time updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ec4fb1155b
commit
f774e0f4f1
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ dist/
|
||||
target/
|
||||
.env
|
||||
*.log
|
||||
ca.crt
|
||||
|
||||
@ -9,16 +9,16 @@
|
||||
"start:prod": "node dist/main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/common": "^11.1.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/core": "^11.1.0",
|
||||
"@nestjs/platform-express": "^11.1.0",
|
||||
"@nestjs/websockets": "^11.1.0",
|
||||
"@nestjs/platform-socket.io": "^11.1.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/typeorm": "^0.3.0",
|
||||
"typeorm": "^0.3.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"pg": "^8.13.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
@ -26,7 +26,8 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"class-transformer": "^0.5.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { MachinesModule } from './machines/machines.module';
|
||||
@ -9,6 +11,18 @@ import { WorkspacesModule } from './workspaces/workspaces.module';
|
||||
import { SessionsModule } from './sessions/sessions.module';
|
||||
import { AgentGateway } from './gateway/agent.gateway';
|
||||
|
||||
function buildSslConfig(config: ConfigService): false | object {
|
||||
if (config.get('DATABASE_SSL', 'true') !== 'true') return false;
|
||||
const caPath = config.get('DATABASE_CA_CERT', '');
|
||||
if (caPath) {
|
||||
const resolved = caPath.startsWith('/') || caPath.match(/^[A-Z]:/i)
|
||||
? caPath
|
||||
: join(process.cwd(), caPath);
|
||||
return { rejectUnauthorized: true, ca: readFileSync(resolved).toString() };
|
||||
}
|
||||
return { rejectUnauthorized: false };
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
@ -22,9 +36,7 @@ import { AgentGateway } from './gateway/agent.gateway';
|
||||
username: config.get('DATABASE_USER', 'gen_user'),
|
||||
password: config.get('DATABASE_PASSWORD', ''),
|
||||
database: config.get('DATABASE_NAME', 'default_db'),
|
||||
ssl: config.get('DATABASE_SSL', 'true') === 'true'
|
||||
? { rejectUnauthorized: true }
|
||||
: false,
|
||||
ssl: buildSslConfig(config),
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
}),
|
||||
|
||||
@ -16,7 +16,7 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const session = await this.repo.findOne({ where: { id }, relations: ['workspace'] });
|
||||
const session = await this.repo.findOne({ where: { id }, relations: ['workspace', 'workspace.machine'] });
|
||||
if (!session) throw new NotFoundException('Session not found');
|
||||
return session;
|
||||
}
|
||||
|
||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface Workspace {
|
||||
@ -21,12 +21,14 @@ interface Project {
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [machines, setMachines] = useState<any[]>([]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [machineId, setMachineId] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [gitUrl, setGitUrl] = useState('');
|
||||
const [launchingWs, setLaunchingWs] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
api.get(`/projects/${params.id}`).then(setProject).catch(() => {});
|
||||
@ -41,6 +43,16 @@ export default function ProjectDetailPage() {
|
||||
load();
|
||||
};
|
||||
|
||||
const openTerminal = async (ws: Workspace) => {
|
||||
setLaunchingWs(ws.id);
|
||||
try {
|
||||
const session = await api.post('/sessions', { workspaceId: ws.id });
|
||||
router.push(`/sessions/${session.id}`);
|
||||
} catch {
|
||||
setLaunchingWs(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
@ -82,6 +94,20 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{ws.path || 'No path set'}</div>
|
||||
{ws.gitUrl && <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{ws.gitUrl} @ {ws.branch}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{ws.machine?.status === 'online' && (
|
||||
<button
|
||||
onClick={() => openTerminal(ws)}
|
||||
disabled={launchingWs === ws.id}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
background: 'var(--primary)', color: '#fff', border: 'none', cursor: 'pointer',
|
||||
opacity: launchingWs === ws.id ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{launchingWs === ws.id ? 'Opening...' : 'Terminal'}
|
||||
</button>
|
||||
)}
|
||||
<div style={{
|
||||
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600,
|
||||
background: ws.machine?.status === 'online' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
@ -91,6 +117,7 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!project.workspaces || project.workspaces.length === 0) && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>No workspaces yet</div>
|
||||
|
||||
127
apps/web/src/app/(dashboard)/sessions/[id]/page.tsx
Normal file
127
apps/web/src/app/(dashboard)/sessions/[id]/page.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { useSocket } from '@/lib/use-socket';
|
||||
import TerminalViewer from '@/components/terminal-wrapper';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
status: string;
|
||||
command: string;
|
||||
startedAt: string;
|
||||
stoppedAt: string | null;
|
||||
workspace: {
|
||||
id: string;
|
||||
path: string;
|
||||
machine: { id: string; name: string; status: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function SessionDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { socket, connected } = useSocket();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/sessions/${params.id}`).then(setSession).catch((e) => setError(e.message));
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !connected || startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
const ageMs = Date.now() - new Date(session.startedAt).getTime();
|
||||
if (session.status === 'running' && ageMs < 10_000) {
|
||||
socket.emit('session:start', {
|
||||
sessionId: session.id,
|
||||
machineId: session.workspace.machine.id,
|
||||
command: session.command,
|
||||
});
|
||||
}
|
||||
}, [session, connected, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const onStatus = (data: { status: string }) => {
|
||||
setSession((prev) => (prev ? { ...prev, status: data.status } : prev));
|
||||
};
|
||||
socket.on(`session:status:${session.id}`, onStatus);
|
||||
return () => { socket.off(`session:status:${session.id}`, onStatus); };
|
||||
}, [session, socket]);
|
||||
|
||||
const handleStop = () => {
|
||||
if (!session) return;
|
||||
socket.emit('session:stop', {
|
||||
sessionId: session.id,
|
||||
machineId: session.workspace.machine.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (error) return <div style={{ color: 'var(--danger)', padding: 40 }}>Error: {error}</div>;
|
||||
if (!session) return <div style={{ padding: 40 }}>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 48px)' }}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 16, flexShrink: 0,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
||||
<button
|
||||
onClick={() => router.push('/sessions')}
|
||||
style={{
|
||||
background: 'none', border: 'none', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', fontSize: 14, padding: 0,
|
||||
}}
|
||||
>
|
||||
← Sessions
|
||||
</button>
|
||||
<h2 style={{ fontSize: 20, margin: 0 }}>{session.command || 'Terminal'}</h2>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{session.workspace.machine.name} — {session.workspace.path || 'default'}
|
||||
{' '}•{' '}
|
||||
<span style={{
|
||||
color: connected ? 'var(--success)' : 'var(--danger)',
|
||||
}}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{
|
||||
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600,
|
||||
background: session.status === 'running' ? 'rgba(34,197,94,0.15)' : 'rgba(136,136,136,0.15)',
|
||||
color: session.status === 'running' ? 'var(--success)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{session.status}
|
||||
</span>
|
||||
{session.status === 'running' && (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
style={{
|
||||
padding: '6px 16px', background: 'var(--danger)', color: '#fff',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<TerminalViewer
|
||||
sessionId={session.id}
|
||||
machineId={session.workspace.machine.id}
|
||||
readOnly={session.status !== 'running'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function SessionsPage() {
|
||||
const router = useRouter();
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<any[]>([]);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newWsId, setNewWsId] = useState('');
|
||||
const [newCmd, setNewCmd] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.get('/projects').then(async (projects: any[]) => {
|
||||
const allWs: any[] = [];
|
||||
for (const p of projects) {
|
||||
@ -22,17 +27,57 @@ export default function SessionsPage() {
|
||||
}
|
||||
setSessions(allSessions);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const createSession = async () => {
|
||||
if (!newWsId) return;
|
||||
const session = await api.post('/sessions', { workspaceId: newWsId, command: newCmd || undefined });
|
||||
setShowNew(false);
|
||||
setNewWsId('');
|
||||
setNewCmd('');
|
||||
router.push(`/sessions/${session.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 24, marginBottom: 24 }}>Sessions</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 24, margin: 0 }}>Sessions</h2>
|
||||
<button onClick={() => setShowNew(!showNew)} style={{
|
||||
padding: '6px 12px', background: 'var(--primary)', color: '#fff',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13,
|
||||
}}>+ New Session</button>
|
||||
</div>
|
||||
|
||||
{showNew && (
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
|
||||
<select value={newWsId} onChange={e => setNewWsId(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px 12px', marginBottom: 8, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: 14 }}>
|
||||
<option value="">Select workspace...</option>
|
||||
{workspaces.map((ws: any) => (
|
||||
<option key={ws.id} value={ws.id}>{ws.machine?.name} — {ws.path || 'default'}</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="Command (optional, default: cmd.exe)" value={newCmd} onChange={e => setNewCmd(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px 12px', marginBottom: 8, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: 14 }} />
|
||||
<button onClick={createSession} style={{ padding: '8px 16px', background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>Create & Connect</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{sessions.map(s => (
|
||||
<div key={s.id} style={{
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => router.push(`/sessions/${s.id}`)}
|
||||
style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
cursor: 'pointer', transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{s.command || 'Session'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{s.machineName} — {s.workspacePath}</div>
|
||||
|
||||
7
apps/web/src/components/terminal-wrapper.tsx
Normal file
7
apps/web/src/components/terminal-wrapper.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const TerminalViewer = dynamic(() => import('./terminal'), { ssr: false });
|
||||
|
||||
export default TerminalViewer;
|
||||
106
apps/web/src/components/terminal.tsx
Normal file
106
apps/web/src/components/terminal.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { socket } from '@/lib/socket';
|
||||
|
||||
interface TerminalViewerProps {
|
||||
sessionId: string;
|
||||
machineId: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function TerminalViewer({ sessionId, machineId, readOnly }: TerminalViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: !readOnly,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#e5e5e5',
|
||||
cursor: '#3b82f6',
|
||||
selectionBackground: 'rgba(59,130,246,0.3)',
|
||||
black: '#0a0a0a',
|
||||
brightBlack: '#666666',
|
||||
red: '#ef4444',
|
||||
brightRed: '#f87171',
|
||||
green: '#22c55e',
|
||||
brightGreen: '#4ade80',
|
||||
yellow: '#eab308',
|
||||
brightYellow: '#facc15',
|
||||
blue: '#3b82f6',
|
||||
brightBlue: '#60a5fa',
|
||||
magenta: '#a855f7',
|
||||
brightMagenta: '#c084fc',
|
||||
cyan: '#06b6d4',
|
||||
brightCyan: '#22d3ee',
|
||||
white: '#e5e5e5',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(containerRef.current);
|
||||
fitAddon.fit();
|
||||
termRef.current = term;
|
||||
|
||||
// Subscribe to session output
|
||||
socket.emit('session:subscribe', { sessionId });
|
||||
|
||||
const onOutput = (data: { output: string }) => {
|
||||
term.write(data.output);
|
||||
};
|
||||
socket.on(`session:output:${sessionId}`, onOutput);
|
||||
|
||||
const onStatus = (data: { status: string }) => {
|
||||
if (data.status === 'stopped' || data.status === 'error') {
|
||||
term.write('\r\n\x1b[90m--- Session ended ---\x1b[0m\r\n');
|
||||
term.options.cursorBlink = false;
|
||||
}
|
||||
};
|
||||
socket.on(`session:status:${sessionId}`, onStatus);
|
||||
|
||||
// Send input to agent
|
||||
if (!readOnly) {
|
||||
term.onData((data) => {
|
||||
socket.emit('session:input', { sessionId, machineId, input: data });
|
||||
});
|
||||
}
|
||||
|
||||
// Resize handling
|
||||
const observer = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
socket.off(`session:output:${sessionId}`, onOutput);
|
||||
socket.off(`session:status:${sessionId}`, onStatus);
|
||||
term.dispose();
|
||||
};
|
||||
}, [sessionId, machineId, readOnly]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: 400,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: '#0a0a0a',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
apps/web/src/lib/socket.ts
Normal file
8
apps/web/src/lib/socket.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'http://localhost:3001/ws';
|
||||
|
||||
export const socket = io(WS_URL, {
|
||||
autoConnect: false,
|
||||
transports: ['websocket'],
|
||||
});
|
||||
23
apps/web/src/lib/use-socket.ts
Normal file
23
apps/web/src/lib/use-socket.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { socket } from './socket';
|
||||
|
||||
export function useSocket() {
|
||||
const [connected, setConnected] = useState(socket.connected);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket.connected) socket.connect();
|
||||
|
||||
const onConnect = () => setConnected(true);
|
||||
const onDisconnect = () => setConnected(false);
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
|
||||
return () => {
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('disconnect', onDisconnect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { socket, connected };
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@ -13,9 +17,24 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
7192
package-lock.json
generated
Normal file
7192
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user