feat(frontend): request logs; refactor frontend components
This commit is contained in:
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "mylinspirer-admin",
|
"name": "mylinspirer-admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"solid-js": "^1.8.0"
|
"solid-js": "^1.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1423,6 +1424,27 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/class-variance-authority": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://polar.sh/cva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"solid-js": "^1.8.0"
|
"solid-js": "^1.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import RulesList from './components/RulesList';
|
|||||||
import CommandQueue from './components/CommandQueue';
|
import CommandQueue from './components/CommandQueue';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import ChangePassword from './components/ChangePassword';
|
import ChangePassword from './components/ChangePassword';
|
||||||
|
import RequestLog from './components/RequestLog';
|
||||||
import { authStore } from './api/auth';
|
import { authStore } from './api/auth';
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const [activeTab, setActiveTab] = createSignal<'rules' | 'commands'>('rules');
|
const [activeTab, setActiveTab] = createSignal<'rules' | 'commands' | 'logs'>('rules');
|
||||||
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
|
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
|
||||||
const [showChangePassword, setShowChangePassword] = createSignal(false);
|
const [showChangePassword, setShowChangePassword] = createSignal(false);
|
||||||
|
|
||||||
@@ -73,6 +74,16 @@ const App: Component = () => {
|
|||||||
>
|
>
|
||||||
Command Queue
|
Command Queue
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
class={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab() === 'logs'
|
||||||
|
? 'border-indigo-500 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Request Log
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -80,6 +91,7 @@ const App: Component = () => {
|
|||||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||||
{activeTab() === 'rules' && <RulesList />}
|
{activeTab() === 'rules' && <RulesList />}
|
||||||
{activeTab() === 'commands' && <CommandQueue />}
|
{activeTab() === 'commands' && <CommandQueue />}
|
||||||
|
{activeTab() === 'logs' && <RequestLog />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Show when={showChangePassword()}>
|
<Show when={showChangePassword()}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest } from '../types';
|
import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest, RequestLog } from '../types';
|
||||||
import { authStore } from './auth';
|
import { authStore } from './auth';
|
||||||
|
|
||||||
const API_BASE = '/admin/api';
|
const API_BASE = '/admin/api';
|
||||||
@@ -103,3 +103,10 @@ export const authApi = {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Logs API
|
||||||
|
export const logsApi = {
|
||||||
|
list: () => request<RequestLog[]>('/logs'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchLogs = logsApi.list;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Component, createSignal } from 'solid-js';
|
import { Component, createSignal } from 'solid-js';
|
||||||
import { authApi } from '../api/client';
|
import { authApi } from '../api/client';
|
||||||
import { authStore } from '../api/auth';
|
import { authStore } from '../api/auth';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader } from './ui/Card';
|
||||||
|
import { Input } from './ui/Input';
|
||||||
|
import { Modal, ModalContent } from './ui/Modal';
|
||||||
|
|
||||||
interface ChangePasswordProps {
|
interface ChangePasswordProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -59,13 +63,14 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
<Modal>
|
||||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
<ModalContent>
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<CardHeader>
|
||||||
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
|
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} class="px-6 py-4 space-y-4">
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent>
|
||||||
{error() && (
|
{error() && (
|
||||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
{error()}
|
{error()}
|
||||||
@@ -82,11 +87,10 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Old Password
|
Old Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={oldPassword()}
|
value={oldPassword()}
|
||||||
onInput={(e) => setOldPassword(e.currentTarget.value)}
|
onInput={(e) => setOldPassword(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,11 +99,10 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
New Password
|
New Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword()}
|
value={newPassword()}
|
||||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,35 +111,33 @@ const ChangePassword: Component<ChangePasswordProps> = (props) => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Confirm New Password
|
Confirm New Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword()}
|
value={confirmPassword()}
|
||||||
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<CardFooter class="flex justify-end space-x-3 pt-4">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
>
|
>
|
||||||
{loading() ? 'Changing...' : 'Change Password'}
|
{loading() ? 'Changing...' : 'Change Password'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</ModalContent>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Component, createResource, For, Show } from 'solid-js';
|
import { Component, createResource, For, Show } from 'solid-js';
|
||||||
import { commandsApi } from '../api/client';
|
import { commandsApi } from '../api/client';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
|
||||||
const CommandQueue: Component = () => {
|
const CommandQueue: Component = () => {
|
||||||
const [commands, { refetch }] = createResource(commandsApi.list);
|
const [commands, { refetch }] = createResource(commandsApi.list);
|
||||||
@@ -22,7 +24,7 @@ const CommandQueue: Component = () => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold">Command Queue</h2>
|
<h2 class="text-2xl font-semibold">Command Queue</h2>
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
<Card>
|
||||||
<Show when={!commands.loading} fallback={<div class="p-4">Loading...</div>}>
|
<Show when={!commands.loading} fallback={<div class="p-4">Loading...</div>}>
|
||||||
<For each={commands()} fallback={
|
<For each={commands()} fallback={
|
||||||
<div class="p-8 text-center text-gray-500">
|
<div class="p-8 text-center text-gray-500">
|
||||||
@@ -56,18 +58,20 @@ const CommandQueue: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={cmd.status === 'unverified'}>
|
<Show when={cmd.status === 'unverified'}>
|
||||||
<div class="ml-4 flex flex-col gap-2">
|
<div class="ml-4 flex flex-col gap-2">
|
||||||
<button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="success"
|
||||||
onClick={() => updateCommandStatus(cmd.id, 'verified')}
|
onClick={() => updateCommandStatus(cmd.id, 'verified')}
|
||||||
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
|
||||||
>
|
>
|
||||||
Verify
|
Verify
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
onClick={() => updateCommandStatus(cmd.id, 'rejected')}
|
onClick={() => updateCommandStatus(cmd.id, 'rejected')}
|
||||||
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
|
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +79,7 @@ const CommandQueue: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Component, createSignal } from 'solid-js';
|
import { Component, createSignal } from 'solid-js';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
import { Input } from './ui/Input';
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
onLoginSuccess: (token: string) => void;
|
onLoginSuccess: (token: string) => void;
|
||||||
@@ -39,7 +42,7 @@ const Login: Component<LoginProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
|
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||||
<div class="bg-white p-8 rounded-lg shadow-md w-96">
|
<Card class="p-8 w-96">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">
|
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||||
My Linspirer Admin Login
|
My Linspirer Admin Login
|
||||||
</h1>
|
</h1>
|
||||||
@@ -49,12 +52,11 @@ const Login: Component<LoginProps> = (props) => {
|
|||||||
<label class="block text-gray-700 text-sm font-medium mb-2" for="password">
|
<label class="block text-gray-700 text-sm font-medium mb-2" for="password">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password()}
|
value={password()}
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
placeholder="Enter admin password"
|
placeholder="Enter admin password"
|
||||||
disabled={isLoading()}
|
disabled={isLoading()}
|
||||||
required
|
required
|
||||||
@@ -67,19 +69,19 @@ const Login: Component<LoginProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
size="lg"
|
||||||
disabled={isLoading()}
|
disabled={isLoading()}
|
||||||
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-400"
|
|
||||||
>
|
>
|
||||||
{isLoading() ? 'Logging in...' : 'Login'}
|
{isLoading() ? 'Logging in...' : 'Login'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-4 text-center text-sm text-gray-500">
|
<div class="mt-4 text-center text-sm text-gray-500">
|
||||||
<p>Default password: admin123</p>
|
<p>Default password: admin123</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
48
frontend/src/components/RequestDetails.tsx
Normal file
48
frontend/src/components/RequestDetails.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, createSignal, For } from 'solid-js';
|
||||||
|
import type { RequestLog } from '../types';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
|
||||||
|
const TreeView: Component<{ data: any; name: string }> = (props) => {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(true);
|
||||||
|
const isObject = typeof props.data === 'object' && props.data !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ml-4 my-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="cursor-pointer" onClick={() => setIsOpen(!isOpen())}>
|
||||||
|
{isObject ? (isOpen() ? '▼' : '►') : ''}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold ml-2">{props.name}</span>
|
||||||
|
</div>
|
||||||
|
{isOpen() && isObject && (
|
||||||
|
<div class="pl-4 border-l-2 border-gray-200">
|
||||||
|
<For each={Object.entries(props.data)}>
|
||||||
|
{([key, value]) => {
|
||||||
|
if (typeof value === 'object' && value !== null && 'modified' in value && 'value' in value) {
|
||||||
|
return <TreeView name={key} data={value.value} />
|
||||||
|
}
|
||||||
|
return <TreeView name={key} data={value} />
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpen() && !isObject && <div class="pl-6 text-gray-700">{JSON.stringify(props.data)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const RequestDetails: Component<{ log: RequestLog }> = (props) => {
|
||||||
|
return (
|
||||||
|
<Card class="p-4">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Request Details</h2>
|
||||||
|
{/* TODO: interception method */}
|
||||||
|
<div>
|
||||||
|
<TreeView name="Request" data={props.log.request_body} />
|
||||||
|
<TreeView name="Response" data={props.log.response_body} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequestDetails;
|
||||||
58
frontend/src/components/RequestLog.tsx
Normal file
58
frontend/src/components/RequestLog.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Component, createSignal, onMount, For, Show } from 'solid-js';
|
||||||
|
import { fetchLogs } from '../api/client';
|
||||||
|
import type { RequestLog as RequestLogType } from '../types';
|
||||||
|
import RequestDetails from './RequestDetails';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
|
||||||
|
const RequestLog: Component = () => {
|
||||||
|
const [logs, setLogs] = createSignal<RequestLogType[]>([]);
|
||||||
|
const [selectedLog, setSelectedLog] = createSignal<RequestLogType | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const fetchedLogs = await fetchLogs();
|
||||||
|
setLogs(fetchedLogs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogClick = (log: RequestLogType) => {
|
||||||
|
setSelectedLog(log);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<div class={selectedLog() ? "w-1/3" : "w-full"}>
|
||||||
|
<h2 class="text-2xl font-semibold">Request Log</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Card>
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
<For each={logs()}>
|
||||||
|
{(log) => (
|
||||||
|
<li
|
||||||
|
class="p-4 hover:bg-gray-50 cursor-pointer"
|
||||||
|
onClick={() => handleLogClick(log)}
|
||||||
|
>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="font-medium">{log.method}</span>
|
||||||
|
<span class="text-sm text-gray-500">{new Date(log.created_at).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={selectedLog()}>
|
||||||
|
{(log) =>
|
||||||
|
<div class="w-2/3">
|
||||||
|
<RequestDetails log={log()} />
|
||||||
|
</div>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequestLog;
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Component, createSignal, createResource, For, Show } from 'solid-js';
|
import { Component, createSignal, createResource, For, Show } from 'solid-js';
|
||||||
import { rulesApi } from '../api/client';
|
import { rulesApi } from '../api/client';
|
||||||
import type { InterceptionRule } from '../types';
|
import type { InterceptionRule } from '../types';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import { Card } from './ui/Card';
|
||||||
|
import { Input } from './ui/Input';
|
||||||
|
import { Select } from './ui/Select';
|
||||||
|
import { Textarea } from './ui/Textarea';
|
||||||
|
|
||||||
const RulesList: Component = () => {
|
const RulesList: Component = () => {
|
||||||
const [rules, { refetch }] = createResource(rulesApi.list);
|
const [rules, { refetch }] = createResource(rulesApi.list);
|
||||||
@@ -105,7 +110,7 @@ const RulesList: Component = () => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h2 class="text-2xl font-semibold">Interception Rules</h2>
|
<h2 class="text-2xl font-semibold">Interception Rules</h2>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showEditor()) {
|
if (showEditor()) {
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
@@ -113,14 +118,13 @@ const RulesList: Component = () => {
|
|||||||
setShowEditor(true);
|
setShowEditor(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
|
|
||||||
>
|
>
|
||||||
{showEditor() ? 'Cancel' : '+ New Rule'}
|
{showEditor() ? 'Cancel' : '+ New Rule'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={showEditor()}>
|
<Show when={showEditor()}>
|
||||||
<div class="bg-white shadow rounded-lg p-6 mb-4">
|
<Card class="p-6 mb-4">
|
||||||
<h3 class="text-lg font-semibold mb-4">
|
<h3 class="text-lg font-semibold mb-4">
|
||||||
{editingId() !== null ? 'Edit Rule' : 'Create New Rule'}
|
{editingId() !== null ? 'Edit Rule' : 'Create New Rule'}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -129,11 +133,10 @@ const RulesList: Component = () => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Method Name
|
Method Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={editingMethod()}
|
value={editingMethod()}
|
||||||
onInput={(e) => setEditingMethod(e.currentTarget.value)}
|
onInput={(e) => setEditingMethod(e.currentTarget.value)}
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
|
||||||
placeholder="com.linspirer.method.name"
|
placeholder="com.linspirer.method.name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,14 +145,13 @@ const RulesList: Component = () => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Action
|
Action
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
value={editingAction()}
|
value={editingAction()}
|
||||||
onChange={(e) => setEditingAction(e.currentTarget.value as any)}
|
onChange={(e) => setEditingAction(e.currentTarget.value as any)}
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
|
||||||
>
|
>
|
||||||
<option value="passthrough">Passthrough (Forward to server)</option>
|
<option value="passthrough">Passthrough (Forward to server)</option>
|
||||||
<option value="replace">Replace (Custom response)</option>
|
<option value="replace">Replace (Custom response)</option>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={editingAction() === 'replace'}>
|
<Show when={editingAction() === 'replace'}>
|
||||||
@@ -157,27 +159,25 @@ const RulesList: Component = () => {
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Custom Response (JSON)
|
Custom Response (JSON)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={editingResponse()}
|
value={editingResponse()}
|
||||||
onInput={(e) => setEditingResponse(e.currentTarget.value)}
|
onInput={(e) => setEditingResponse(e.currentTarget.value)}
|
||||||
rows={10}
|
rows={10}
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm"
|
|
||||||
placeholder='{"code": 0, "type": "object", "data": {...}}'
|
placeholder='{"code": 0, "type": "object", "data": {...}}'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
|
||||||
>
|
>
|
||||||
{editingId() !== null ? 'Update Rule' : 'Create Rule'}
|
{editingId() !== null ? 'Update Rule' : 'Create Rule'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
<Card>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
@@ -225,16 +225,18 @@ const RulesList: Component = () => {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
onClick={() => startEdit(rule)}
|
onClick={() => startEdit(rule)}
|
||||||
class="text-indigo-600 hover:text-indigo-900 mr-4">
|
class="mr-4">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="link"
|
||||||
onClick={() => deleteRule(rule.id)}
|
onClick={() => deleteRule(rule.id)}
|
||||||
class="text-red-600 hover:text-red-900">
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -243,7 +245,7 @@ const RulesList: Component = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
41
frontend/src/components/ui/Button.tsx
Normal file
41
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Component, JSX, splitProps } from 'solid-js';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: 'bg-indigo-600 text-white hover:bg-indigo-700',
|
||||||
|
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
success: 'bg-green-600 text-white hover:bg-green-700',
|
||||||
|
ghost: 'text-indigo-600 hover:text-indigo-900',
|
||||||
|
link: 'text-red-600 hover:text-red-900',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'px-3 py-1',
|
||||||
|
md: 'px-4 py-2',
|
||||||
|
lg: 'w-full px-4 py-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button: Component<ButtonProps> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['variant', 'size', 'class']);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
class={buttonVariants({ variant: local.variant, size: local.size, class: local.class })}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
59
frontend/src/components/ui/Card.tsx
Normal file
59
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Component, JSX, splitProps } from 'solid-js';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
const cardVariants = cva('bg-white shadow rounded-lg', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: 'overflow-hidden',
|
||||||
|
withPadding: 'p-6',
|
||||||
|
withPaddingAndDivider: 'p-4 divide-y divide-gray-200',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CardProps extends JSX.HTMLAttributes<HTMLDivElement>, VariantProps<typeof cardVariants> {}
|
||||||
|
|
||||||
|
const Card: Component<CardProps> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['variant', 'class']);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cardVariants({ variant: local.variant, class: local.class })}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardHeader: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class']);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`px-6 py-4 border-b border-gray-200 ${local.class}`}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardContent: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class']);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`px-6 py-4 space-y-4 ${local.class}`}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardFooter: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class']);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`px-6 py-4 border-t border-gray-200 ${local.class}`}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardContent, CardFooter, cardVariants };
|
||||||
30
frontend/src/components/ui/Input.tsx
Normal file
30
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component, JSX, splitProps } from 'solid-js';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
const inputVariants = cva(
|
||||||
|
'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement>, VariantProps<typeof inputVariants> {}
|
||||||
|
|
||||||
|
const Input: Component<InputProps> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['variant', 'class']);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class={inputVariants({ variant: local.variant, class: local.class })}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Input, inputVariants };
|
||||||
23
frontend/src/components/ui/Modal.tsx
Normal file
23
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Component, JSX, splitProps } from 'solid-js';
|
||||||
|
|
||||||
|
const Modal: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class']);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 ${local.class}`}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModalContent: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class']);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`bg-white rounded-lg shadow-xl max-w-md w-full mx-4 ${local.class}`}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Modal, ModalContent };
|
||||||
30
frontend/src/components/ui/Select.tsx
Normal file
30
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component, JSX, splitProps } from 'solid-js';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
const selectVariants = cva(
|
||||||
|
'w-full border border-gray-300 rounded-md px-3 py-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface SelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement>, VariantProps<typeof selectVariants> {}
|
||||||
|
|
||||||
|
const Select: Component<SelectProps> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['variant', 'class']);
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
class={selectVariants({ variant: local.variant, class: local.class })}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Select, selectVariants };
|
||||||
30
frontend/src/components/ui/Textarea.tsx
Normal file
30
frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component, JSX, splitProps } from 'solid-js';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
const textareaVariants = cva(
|
||||||
|
'w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, VariantProps<typeof textareaVariants> {}
|
||||||
|
|
||||||
|
const Textarea: Component<TextareaProps> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['variant', 'class']);
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
class={textareaVariants({ variant: local.variant, class: local.class })}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Textarea, textareaVariants };
|
||||||
@@ -34,3 +34,23 @@ export interface UpdateCommandRequest {
|
|||||||
status: string;
|
status: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
headers: Record<string, {value: string, modified: boolean}>;
|
||||||
|
body: {value: any, modified: boolean};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Response {
|
||||||
|
headers: Record<string, {value: string, modified: boolean}>;
|
||||||
|
body: {value: any, modified: boolean};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestLog {
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
request_body: object;
|
||||||
|
response_body: object;
|
||||||
|
request_interception_action: string,
|
||||||
|
response_interception_action: string,
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,21 +39,8 @@ CREATE TABLE IF NOT EXISTS commands (
|
|||||||
notes TEXT
|
notes TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Request/Response logs (optional, can be large)
|
-- Index for performance
|
||||||
CREATE TABLE IF NOT EXISTS request_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
method TEXT,
|
|
||||||
path TEXT,
|
|
||||||
request_body TEXT,
|
|
||||||
response_body TEXT,
|
|
||||||
status_code INTEGER,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes for performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
|
CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON request_logs(timestamp);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_method ON request_logs(method);
|
|
||||||
|
|
||||||
-- Insert default config values
|
-- Insert default config values
|
||||||
INSERT OR IGNORE INTO config (key, value, description) VALUES
|
INSERT OR IGNORE INTO config (key, value, description) VALUES
|
||||||
|
|||||||
14
migrations/20251201181400_add_request_logs.sql
Normal file
14
migrations/20251201181400_add_request_logs.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Add request logs
|
||||||
|
CREATE TABLE request_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
method TEXT,
|
||||||
|
request_body TEXT,
|
||||||
|
response_body TEXT,
|
||||||
|
request_interception_action TEXT,
|
||||||
|
response_interception_action TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON request_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_method ON request_logs(method);
|
||||||
@@ -7,7 +7,10 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::{AppState, auth, crypto, db};
|
use crate::{
|
||||||
|
AppState, auth,
|
||||||
|
db::{self, models::RequestLog},
|
||||||
|
};
|
||||||
|
|
||||||
use super::models::*;
|
use super::models::*;
|
||||||
|
|
||||||
@@ -348,3 +351,19 @@ pub async fn update_config(
|
|||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log handlers
|
||||||
|
pub async fn list_logs(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<RequestLog>>, (StatusCode, Json<ApiError>)> {
|
||||||
|
match db::repositories::logs::list_all(&state.db).await {
|
||||||
|
Ok(logs) => Ok(Json(logs)),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to list logs: {}", e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ApiError::new("Failed to fetch logs")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,3 +99,21 @@ impl ApiError {
|
|||||||
Self { error: msg.into() }
|
Self { error: msg.into() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RequestDetails {
|
||||||
|
pub headers: Vec<Header>,
|
||||||
|
pub body: Body,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Body {
|
||||||
|
pub value: Value,
|
||||||
|
pub modified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Header {
|
||||||
|
pub value: String,
|
||||||
|
pub modified: bool,
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub fn admin_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
.route("/api/commands/{:id}", post(handlers::verify_command))
|
.route("/api/commands/{:id}", post(handlers::verify_command))
|
||||||
.route("/api/config", get(handlers::get_config))
|
.route("/api/config", get(handlers::get_config))
|
||||||
.route("/api/config", put(handlers::update_config))
|
.route("/api/config", put(handlers::update_config))
|
||||||
|
.route("/api/logs", get(handlers::list_logs))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
state,
|
state,
|
||||||
auth_middleware::auth_middleware,
|
auth_middleware::auth_middleware,
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ pub mod models;
|
|||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||||
use std::str::FromStr;
|
use std::{path::Path, str::FromStr};
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
|
pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
|
||||||
info!("Initializing database at: {}", database_url);
|
info!("Initializing database at: {}", database_url);
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
{
|
||||||
|
// remove scheme from the URL
|
||||||
|
let url = database_url
|
||||||
|
.trim_start_matches("sqlite://")
|
||||||
|
.trim_start_matches("sqlite:");
|
||||||
|
let path = url.split('?').next().unwrap_or_default();
|
||||||
|
if let Some(dir) = Path::new(path).parent()
|
||||||
|
&& let Err(err) = tokio::fs::create_dir_all(dir).await
|
||||||
|
{
|
||||||
|
warn!("Failed to create directories for database ({database_url}): {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
// Parse connection options
|
// Parse connection options
|
||||||
let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct InterceptionRule {
|
pub struct InterceptionRule {
|
||||||
@@ -29,23 +30,13 @@ pub struct Config {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct ResponseTemplate {
|
|
||||||
pub id: i64,
|
|
||||||
pub name: String,
|
|
||||||
pub method_name: String,
|
|
||||||
pub response_json: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub created_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct RequestLog {
|
pub struct RequestLog {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub method: Option<String>,
|
pub method: String,
|
||||||
pub path: String,
|
pub request_body: Value,
|
||||||
pub request_body: String,
|
pub response_body: Value,
|
||||||
pub response_body: String,
|
pub request_interception_action: String,
|
||||||
pub status_code: i32,
|
pub response_interception_action: String,
|
||||||
pub timestamp: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/db/repositories/logs.rs
Normal file
39
src/db/repositories/logs.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use crate::db::models::RequestLog;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
pub async fn list_all(pool: &SqlitePool) -> anyhow::Result<Vec<RequestLog>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, RequestLog>("SELECT * FROM request_logs ORDER BY created_at")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
method: String,
|
||||||
|
request_body: Value,
|
||||||
|
response_body: Value,
|
||||||
|
request_interception_action: String,
|
||||||
|
response_interception_action: String,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO request_logs (
|
||||||
|
method,
|
||||||
|
request_body,
|
||||||
|
response_body,
|
||||||
|
request_interception_action,
|
||||||
|
response_interception_action
|
||||||
|
) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(method)
|
||||||
|
.bind(request_body)
|
||||||
|
.bind(response_body)
|
||||||
|
.bind(request_interception_action)
|
||||||
|
.bind(response_interception_action)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid())
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod logs;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{Router, routing::any};
|
|
||||||
use axum::handler::Handler;
|
use axum::handler::Handler;
|
||||||
|
use axum::{Router, routing::any};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use tower_http::compression::CompressionLayer;
|
use tower_http::compression::CompressionLayer;
|
||||||
use tracing::{error, info, level_filters::LevelFilter};
|
use tracing::{error, info, level_filters::LevelFilter};
|
||||||
@@ -88,7 +88,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Admin routes
|
// Admin routes
|
||||||
.nest("/admin", admin::routes::admin_routes(state.clone()))
|
.nest("/admin", admin::routes::admin_routes(state.clone()))
|
||||||
// Proxy Linspirer APIs
|
// Proxy Linspirer APIs
|
||||||
.route("/public-interface.php", any(proxy::proxy_handler.layer(proxy_middleware)))
|
.route(
|
||||||
|
"/public-interface.php",
|
||||||
|
any(proxy::proxy_handler.layer(proxy_middleware)),
|
||||||
|
)
|
||||||
.layer(CompressionLayer::new().gzip(true))
|
.layer(CompressionLayer::new().gzip(true))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::str;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{OriginalUri, State},
|
extract::State,
|
||||||
http::{Request, StatusCode},
|
http::{Request, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
@@ -11,7 +11,7 @@ use http_body_util::BodyExt;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::{AppState, crypto};
|
use crate::{AppState, crypto, db};
|
||||||
|
|
||||||
enum ResponseBody {
|
enum ResponseBody {
|
||||||
Original(String),
|
Original(String),
|
||||||
@@ -20,7 +20,6 @@ enum ResponseBody {
|
|||||||
|
|
||||||
pub async fn middleware(
|
pub async fn middleware(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
OriginalUri(uri): OriginalUri,
|
|
||||||
req: Request<axum::body::Body>,
|
req: Request<axum::body::Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -37,9 +36,7 @@ pub async fn middleware(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = uri.path();
|
let (decrypted_request, method) = match str::from_utf8(&body_bytes)
|
||||||
|
|
||||||
let (decrypted_request_for_log, method) = match str::from_utf8(&body_bytes)
|
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(anyhow::Error::from)
|
||||||
.and_then(|body| process_and_log_request(body, &state.key, &state.iv))
|
.and_then(|body| process_and_log_request(body, &state.key, &state.iv))
|
||||||
{
|
{
|
||||||
@@ -78,9 +75,8 @@ pub async fn middleware(
|
|||||||
let resp_body_text = String::from_utf8(body_bytes.clone().to_vec()).unwrap_or_default();
|
let resp_body_text = String::from_utf8(body_bytes.clone().to_vec()).unwrap_or_default();
|
||||||
|
|
||||||
// Check for generic method interception first
|
// Check for generic method interception first
|
||||||
let response_body_to_log = if let Some(method_str) = &method {
|
let response_body = if let Some(method_str) = &method {
|
||||||
if let Ok(Some(intercepted)) =
|
if let Ok(Some(intercepted)) = intercept_response(method_str, &resp_body_text, &state).await
|
||||||
maybe_intercept_response(method_str, &resp_body_text, &state).await
|
|
||||||
{
|
{
|
||||||
info!("Intercepting response for method: {}", method_str);
|
info!("Intercepting response for method: {}", method_str);
|
||||||
ResponseBody::Original(intercepted)
|
ResponseBody::Original(intercepted)
|
||||||
@@ -108,28 +104,46 @@ pub async fn middleware(
|
|||||||
ResponseBody::Original(resp_body_text.clone())
|
ResponseBody::Original(resp_body_text.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
let (decrypted_response_for_log, final_response_body) = match response_body_to_log {
|
let (decrypted_response, final_response_body) = match response_body {
|
||||||
ResponseBody::Original(body_text) => {
|
ResponseBody::Original(body_text) => {
|
||||||
let decrypted = decrypt_and_format(&body_text, &state.key, &state.iv)
|
let decrypted =
|
||||||
.unwrap_or_else(|_| "Could not decrypt or format response".to_string());
|
decrypt_and_format(&body_text, &state.key, &state.iv).unwrap_or_else(|_| {
|
||||||
|
Value::String("Could not decrypt or format response".to_string())
|
||||||
|
});
|
||||||
(decrypted, body_text)
|
(decrypted, body_text)
|
||||||
}
|
}
|
||||||
ResponseBody::Modified(body_value) => {
|
ResponseBody::Modified(response_body_value) => {
|
||||||
let pretty_printed = serde_json::to_string_pretty(&body_value).unwrap_or_default();
|
let pretty_printed =
|
||||||
|
serde_json::to_string_pretty(&response_body_value).unwrap_or_default();
|
||||||
let encrypted = crypto::encrypt(&pretty_printed, &state.key, &state.iv)
|
let encrypted = crypto::encrypt(&pretty_printed, &state.key, &state.iv)
|
||||||
.unwrap_or_else(|_| "Failed to encrypt modified response".to_string());
|
.unwrap_or_else(|_| "Failed to encrypt modified response".to_string());
|
||||||
(pretty_printed, encrypted)
|
(response_body_value, encrypted)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"{}\nRequest:\n{}\nResponse:\n{}\n{}",
|
"\nRequest:\n{}\nResponse:\n{}\n{}",
|
||||||
path,
|
serde_json::to_string_pretty(&decrypted_request).unwrap_or_default(),
|
||||||
serde_json::to_string_pretty(&decrypted_request_for_log).unwrap_or_default(),
|
serde_json::to_string_pretty(&decrypted_response).unwrap_or_default(),
|
||||||
decrypted_response_for_log,
|
|
||||||
"-".repeat(80),
|
"-".repeat(80),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if let Some(method) = method {
|
||||||
|
// TODO: interception action
|
||||||
|
if let Err(e) = db::repositories::logs::create(
|
||||||
|
&state.db,
|
||||||
|
method,
|
||||||
|
decrypted_request,
|
||||||
|
decrypted_response,
|
||||||
|
"".into(),
|
||||||
|
"".into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("Failed to log request: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut response_builder = Response::builder().status(resp_parts.status);
|
let mut response_builder = Response::builder().status(resp_parts.status);
|
||||||
if !resp_parts.headers.is_empty() {
|
if !resp_parts.headers.is_empty() {
|
||||||
*response_builder.headers_mut().unwrap() = resp_parts.headers;
|
*response_builder.headers_mut().unwrap() = resp_parts.headers;
|
||||||
@@ -220,13 +234,12 @@ async fn handle_getcommand_response(
|
|||||||
Ok(response_json)
|
Ok(response_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<String> {
|
fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<Value> {
|
||||||
let decrypted = crypto::decrypt(body_text, key, iv)?;
|
let decrypted = crypto::decrypt(body_text, key, iv)?;
|
||||||
let formatted: Value = serde_json::from_str(&decrypted)?;
|
Ok(serde_json::from_str(&decrypted)?)
|
||||||
Ok(serde_json::to_string_pretty(&formatted)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn maybe_intercept_response(
|
async fn intercept_response(
|
||||||
method: &str,
|
method: &str,
|
||||||
_original_response: &str,
|
_original_response: &str,
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
|
|||||||
Reference in New Issue
Block a user