Replace the generic mcp-call socket path with two authenticated, read-only Git actions. Add response normalization and error masking, a purpose-built frontend, container build configuration, and socket handler unit tests.
189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { io, Socket } from 'socket.io-client';
|
|
|
|
interface GitStatusData {
|
|
status: string;
|
|
details: string;
|
|
}
|
|
|
|
interface GitBranchData {
|
|
current_branch: string;
|
|
}
|
|
|
|
function isDataEnvelope(payload: unknown): payload is { data: unknown } {
|
|
return (
|
|
typeof payload === 'object' &&
|
|
payload !== null &&
|
|
!Array.isArray(payload) &&
|
|
'data' in payload
|
|
);
|
|
}
|
|
|
|
function isGitStatusData(data: unknown): data is GitStatusData {
|
|
const d = data as GitStatusData;
|
|
return (
|
|
!!d &&
|
|
typeof d === 'object' &&
|
|
!Array.isArray(d) &&
|
|
typeof d.status === 'string' &&
|
|
typeof d.details === 'string'
|
|
);
|
|
}
|
|
|
|
function isGitBranchData(data: unknown): data is GitBranchData {
|
|
const d = data as GitBranchData;
|
|
return (
|
|
!!d &&
|
|
typeof d === 'object' &&
|
|
!Array.isArray(d) &&
|
|
typeof d.current_branch === 'string'
|
|
);
|
|
}
|
|
|
|
interface User {
|
|
email: string;
|
|
name?: string;
|
|
}
|
|
|
|
export default function App() {
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [statusData, setStatusData] = useState<GitStatusData | null>(null);
|
|
const [branchData, setBranchData] = useState<GitBranchData | null>(null);
|
|
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
|
const [branchLoading, setBranchLoading] = useState<boolean>(false);
|
|
const [statusError, setStatusError] = useState<string | null>(null);
|
|
const [branchError, setBranchError] = useState<string | null>(null);
|
|
|
|
const socketRef = useRef<Socket | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/auth/me', { credentials: 'include' })
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then(setUser);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
const socket = io('/');
|
|
socketRef.current = socket;
|
|
|
|
const onStatusResult = (payload: unknown) => {
|
|
setStatusLoading(false);
|
|
if (isDataEnvelope(payload) && isGitStatusData(payload.data)) {
|
|
setStatusData({ status: payload.data.status, details: payload.data.details });
|
|
} else {
|
|
setStatusData(null);
|
|
setStatusError('Ugyldig svar for Git-status.');
|
|
}
|
|
};
|
|
|
|
const onStatusError = () => {
|
|
setStatusData(null);
|
|
setStatusLoading(false);
|
|
setStatusError('Kunne ikke hente Git-status.');
|
|
};
|
|
|
|
const onBranchResult = (payload: unknown) => {
|
|
setBranchLoading(false);
|
|
if (isDataEnvelope(payload) && isGitBranchData(payload.data)) {
|
|
setBranchData({ current_branch: payload.data.current_branch });
|
|
} else {
|
|
setBranchData(null);
|
|
setBranchError('Ugyldig svar for aktiv branch.');
|
|
}
|
|
};
|
|
|
|
const onBranchError = () => {
|
|
setBranchData(null);
|
|
setBranchLoading(false);
|
|
setBranchError('Kunne ikke hente aktiv branch.');
|
|
};
|
|
|
|
socket.on('git:status:result', onStatusResult);
|
|
socket.on('git:status:error', onStatusError);
|
|
socket.on('git:branch:result', onBranchResult);
|
|
socket.on('git:branch:error', onBranchError);
|
|
|
|
return () => {
|
|
socket.off('git:status:result', onStatusResult);
|
|
socket.off('git:status:error', onStatusError);
|
|
socket.off('git:branch:result', onBranchResult);
|
|
socket.off('git:branch:error', onBranchError);
|
|
socket.close();
|
|
socketRef.current = null;
|
|
};
|
|
}, [user]);
|
|
|
|
const handleGetStatus = () => {
|
|
if (!socketRef.current) {
|
|
setStatusData(null);
|
|
setStatusLoading(false);
|
|
setStatusError('Kunne ikke hente Git-status.');
|
|
return;
|
|
}
|
|
setStatusError(null);
|
|
setStatusData(null);
|
|
setStatusLoading(true);
|
|
socketRef.current.emit('git:getStatus');
|
|
};
|
|
|
|
const handleGetBranch = () => {
|
|
if (!socketRef.current) {
|
|
setBranchData(null);
|
|
setBranchLoading(false);
|
|
setBranchError('Kunne ikke hente aktiv branch.');
|
|
return;
|
|
}
|
|
setBranchError(null);
|
|
setBranchData(null);
|
|
setBranchLoading(true);
|
|
socketRef.current.emit('git:getBranch');
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<main className="login">
|
|
<h1>OPAX</h1>
|
|
<a href="/auth/google">Continue with Google</a>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main>
|
|
<header>
|
|
<b>OPAX / Web TUI</b>
|
|
<span>{user.email}</span>
|
|
</header>
|
|
<section>
|
|
<div>
|
|
<h2>Git Status</h2>
|
|
<button onClick={handleGetStatus} disabled={statusLoading}>
|
|
{statusLoading ? 'Laster...' : 'Hent Git-status'}
|
|
</button>
|
|
{statusError && <p className="error">{statusError}</p>}
|
|
{statusData && (
|
|
<pre>
|
|
<strong>Status:</strong> {statusData.status}\n\n
|
|
<strong>Detaljer:</strong>\n{statusData.details || '(ingen endringer)'}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h2>Aktiv Branch</h2>
|
|
<button onClick={handleGetBranch} disabled={branchLoading}>
|
|
{branchLoading ? 'Laster...' : 'Hent aktiv branch'}
|
|
</button>
|
|
{branchError && <p className="error">{branchError}</p>}
|
|
{branchData && (
|
|
<p>
|
|
<strong>Nåværende branch:</strong> {branchData.current_branch}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|