feat(frontend): request logs; refactor frontend components

This commit is contained in:
2025-12-01 18:48:22 +08:00
parent d783cf2591
commit a9cb9510c5
28 changed files with 649 additions and 160 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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()}>

View File

@@ -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;

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View 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;

View 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;

View File

@@ -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>
); );
}; };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View File

@@ -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;
}

View File

@@ -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

View 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);

View File

@@ -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")),
))
}
}
}

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,
} }

View 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())
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>,