feat: web frontend; middleware; serde (WIP?)

This commit is contained in:
2025-11-30 09:41:37 +08:00
parent be35040e26
commit 531ac029af
45 changed files with 6806 additions and 82 deletions

7
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Ignore node_modules and build artifacts
node_modules/
dist/
*.log
# Keep the placeholder index.html for development
!dist/index.html

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Linspirer Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

2780
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "mylinspirer-admin",
"version": "0.1.0",
"description": "Admin frontend for My Linspirer MITM Server",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"solid-js": "^1.8.0"
},
"devDependencies": {
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"terser": "^5.44.1",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-plugin-solid": "^2.10.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

96
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { Component, createSignal, onMount, Show } from 'solid-js';
import RulesList from './components/RulesList';
import CommandQueue from './components/CommandQueue';
import Login from './components/Login';
import ChangePassword from './components/ChangePassword';
import { authStore } from './api/auth';
const App: Component = () => {
const [activeTab, setActiveTab] = createSignal<'rules' | 'commands'>('rules');
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
const [showChangePassword, setShowChangePassword] = createSignal(false);
onMount(() => {
// Check if user is already authenticated
setIsAuthenticated(authStore.isAuthenticated());
});
const handleLoginSuccess = (token: string) => {
authStore.setToken(token);
setIsAuthenticated(true);
};
const handleLogout = () => {
authStore.clearToken();
setIsAuthenticated(false);
};
return (
<Show when={isAuthenticated()} fallback={<Login onLoginSuccess={handleLoginSuccess} />}>
<div class="min-h-screen bg-gray-100">
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 py-6 flex justify-between items-center">
<h1 class="text-3xl font-bold text-gray-900">
My Linspirer Control Panel
</h1>
<div class="flex space-x-3">
<button
onClick={() => setShowChangePassword(true)}
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"
>
Change Password
</button>
<button
onClick={handleLogout}
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"
>
Logout
</button>
</div>
</div>
</header>
<nav class="bg-white border-b">
<div class="max-w-7xl mx-auto px-4">
<div class="flex space-x-8">
<button
onClick={() => setActiveTab('rules')}
class={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'rules'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Interception Rules
</button>
<button
onClick={() => setActiveTab('commands')}
class={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'commands'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Command Queue
</button>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-6">
{activeTab() === 'rules' && <RulesList />}
{activeTab() === 'commands' && <CommandQueue />}
</main>
<Show when={showChangePassword()}>
<ChangePassword
onClose={() => setShowChangePassword(false)}
onLogout={handleLogout}
/>
</Show>
</div>
</Show>
);
};
export default App;

19
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,19 @@
const TOKEN_KEY = 'admin_token';
export const authStore = {
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},
clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
},
isAuthenticated(): boolean {
return !!this.getToken();
},
};

105
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest } from '../types';
import { authStore } from './auth';
const API_BASE = '/admin/api';
async function request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const token = authStore.getToken();
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options?.headers,
},
...options,
});
if (response.status === 401) {
// Unauthorized - clear token and trigger re-authentication
authStore.clearToken();
window.location.reload();
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || `API Error: ${response.statusText}`);
}
if (response.status === 204 || response.status === 200) {
// Check if response has content
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return undefined as T;
}
// Check if there's actually content to parse
const text = await response.text();
if (!text || text.trim() === '') {
return undefined as T;
}
return JSON.parse(text);
}
return response.json();
}
// Rules API
export const rulesApi = {
list: () =>
request<InterceptionRule[]>('/rules'),
create: (rule: CreateRuleRequest) =>
request<InterceptionRule>('/rules', {
method: 'POST',
body: JSON.stringify(rule),
}),
update: (id: number, rule: UpdateRuleRequest) =>
request<InterceptionRule>(`/rules/${id}`, {
method: 'PUT',
body: JSON.stringify(rule),
}),
delete: (id: number) =>
request<void>(`/rules/${id}`, { method: 'DELETE' }),
};
// Commands API
export const commandsApi = {
list: (status?: string) =>
request<Command[]>(`/commands${status ? `?status=${status}` : ''}`),
updateStatus: (id: number, req: UpdateCommandRequest) =>
request<Command>(`/commands/${id}`, {
method: 'POST',
body: JSON.stringify(req),
}),
};
// Config API
export const configApi = {
get: () => request<Record<string, string>>('/config'),
update: (config: Record<string, string>) =>
request<void>('/config', {
method: 'PUT',
body: JSON.stringify(config),
}),
};
// Auth API
export const authApi = {
changePassword: (oldPassword: string, newPassword: string) =>
request<void>('/password', {
method: 'PUT',
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
}),
}),
};

View File

@@ -0,0 +1,143 @@
import { Component, createSignal } from 'solid-js';
import { authApi } from '../api/client';
import { authStore } from '../api/auth';
interface ChangePasswordProps {
onClose: () => void;
onLogout: () => void;
}
const ChangePassword: Component<ChangePasswordProps> = (props) => {
const [oldPassword, setOldPassword] = createSignal('');
const [newPassword, setNewPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [error, setError] = createSignal<string | null>(null);
const [success, setSuccess] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setError(null);
setSuccess(false);
// Validation
if (!oldPassword() || !newPassword() || !confirmPassword()) {
setError('Please fill in all fields');
return;
}
if (newPassword() !== confirmPassword()) {
setError('New passwords do not match');
return;
}
if (newPassword().length < 6) {
setError('New password must be at least 6 characters');
return;
}
setLoading(true);
try {
await authApi.changePassword(oldPassword(), newPassword());
setSuccess(true);
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
// Close modal and logout after 2 seconds
setTimeout(() => {
props.onClose();
authStore.clearToken();
props.onLogout();
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setLoading(false);
}
};
return (
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
</div>
<form onSubmit={handleSubmit} class="px-6 py-4 space-y-4">
{error() && (
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error()}
</div>
)}
{success() && (
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
Password changed successfully! You will be logged out...
</div>
)}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Old Password
</label>
<input
type="password"
value={oldPassword()}
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()}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
type="password"
value={newPassword()}
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()}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password
</label>
<input
type="password"
value={confirmPassword()}
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()}
/>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
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()}
>
Cancel
</button>
<button
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()}
>
{loading() ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ChangePassword;

View File

@@ -0,0 +1,83 @@
import { Component, createResource, For, Show } from 'solid-js';
import { commandsApi } from '../api/client';
const CommandQueue: Component = () => {
const [commands, { refetch }] = createResource(commandsApi.list);
const updateCommandStatus = async (id: number, status: string) => {
try {
await commandsApi.updateStatus(id, { status });
refetch();
} catch (err) {
console.error('Failed to update command:', err);
alert(`Error: ${err}`);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
return (
<div class="space-y-4">
<h2 class="text-2xl font-semibold">Command Queue</h2>
<div class="bg-white shadow rounded-lg overflow-hidden">
<Show when={!commands.loading} fallback={<div class="p-4">Loading...</div>}>
<For each={commands()} fallback={
<div class="p-8 text-center text-gray-500">
No commands in queue
</div>
}>
{(cmd) => (
<div class="border-b p-4 hover:bg-gray-50">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class={`px-2 py-1 rounded-full text-xs font-semibold ${
cmd.status === 'verified' ? 'bg-green-100 text-green-800' :
cmd.status === 'rejected' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{cmd.status}
</span>
<span class="text-sm text-gray-500">
{formatDate(cmd.received_at)}
</span>
</div>
<pre class="text-sm bg-gray-100 p-3 rounded overflow-x-auto">
{JSON.stringify(cmd.command, null, 2)}
</pre>
<Show when={cmd.notes}>
<div class="mt-2 text-sm text-gray-600">
<strong>Notes:</strong> {cmd.notes}
</div>
</Show>
</div>
<Show when={cmd.status === 'unverified'}>
<div class="ml-4 flex flex-col gap-2">
<button
onClick={() => updateCommandStatus(cmd.id, 'verified')}
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
>
Verify
</button>
<button
onClick={() => updateCommandStatus(cmd.id, 'rejected')}
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
>
Reject
</button>
</div>
</Show>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
);
};
export default CommandQueue;

View File

@@ -0,0 +1,87 @@
import { Component, createSignal } from 'solid-js';
interface LoginProps {
onLoginSuccess: (token: string) => void;
}
const Login: Component<LoginProps> = (props) => {
const [password, setPassword] = createSignal('');
const [error, setError] = createSignal('');
const [isLoading, setIsLoading] = createSignal(false);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch('/admin/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: password() }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Login failed' }));
throw new Error(errorData.error || 'Invalid password');
}
const data = await response.json();
props.onLoginSuccess(data.token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<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">
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">
My Linspirer Admin Login
</h1>
<form onSubmit={handleSubmit}>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-medium mb-2" for="password">
Password
</label>
<input
id="password"
type="password"
value={password()}
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"
disabled={isLoading()}
required
/>
</div>
{error() && (
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-sm text-red-600">{error()}</p>
</div>
)}
<button
type="submit"
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'}
</button>
</form>
<div class="mt-4 text-center text-sm text-gray-500">
<p>Default password: admin123</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,266 @@
import { Component, createSignal, createResource, For, Show } from 'solid-js';
import { rulesApi } from '../api/client';
import type { InterceptionRule } from '../types';
const RulesList: Component = () => {
const [rules, { refetch }] = createResource(rulesApi.list);
const [showEditor, setShowEditor] = createSignal(false);
const [editingId, setEditingId] = createSignal<number | null>(null);
const [editingMethod, setEditingMethod] = createSignal('');
const [editingAction, setEditingAction] = createSignal<'passthrough' | 'modify' | 'replace'>('passthrough');
const [editingResponse, setEditingResponse] = createSignal('');
const toggleRule = async (rule: InterceptionRule) => {
try {
await rulesApi.update(rule.id, {
is_enabled: !rule.is_enabled,
});
refetch();
} catch (err) {
console.error('Failed to toggle rule:', err);
alert(`Error: ${err}`);
}
};
const deleteRule = async (id: number) => {
if (!confirm('Are you sure you want to delete this rule?')) return;
try {
await rulesApi.delete(id);
refetch();
} catch (err) {
console.error('Failed to delete rule:', err);
alert(`Error: ${err}`);
}
};
const createNewRule = async () => {
if (!editingMethod()) {
alert('Please enter a method name');
return;
}
try {
await rulesApi.create({
method_name: editingMethod(),
action: editingAction(),
custom_response: editingAction() === 'replace' ? editingResponse() : undefined,
});
setShowEditor(false);
setEditingMethod('');
setEditingResponse('');
refetch();
} catch (err) {
console.error('Failed to create rule:', err);
alert(`Error: ${err}`);
}
};
const startEdit = (rule: InterceptionRule) => {
setEditingId(rule.id);
setEditingMethod(rule.method_name);
setEditingAction(rule.action);
setEditingResponse(rule.custom_response || '');
setShowEditor(true);
};
const cancelEdit = () => {
setEditingId(null);
setEditingMethod('');
setEditingAction('passthrough');
setEditingResponse('');
setShowEditor(false);
};
const saveEdit = async () => {
const id = editingId();
if (id === null) {
await createNewRule();
return;
}
if (!editingMethod()) {
alert('Please enter a method name');
return;
}
try {
await rulesApi.update(id, {
method_name: editingMethod(),
action: editingAction(),
custom_response: editingAction() === 'replace' ? editingResponse() : undefined,
});
cancelEdit();
refetch();
} catch (err) {
console.error('Failed to update rule:', err);
alert(`Error: ${err}`);
}
};
return (
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-semibold">Interception Rules</h2>
<button
onClick={() => {
if (showEditor()) {
cancelEdit();
} else {
setShowEditor(true);
}
}}
class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
>
{showEditor() ? 'Cancel' : '+ New Rule'}
</button>
</div>
<Show when={showEditor()}>
<div class="bg-white shadow rounded-lg p-6 mb-4">
<h3 class="text-lg font-semibold mb-4">
{editingId() !== null ? 'Edit Rule' : 'Create New Rule'}
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Method Name
</label>
<Show
when={editingId() === null}
fallback={
<input
type="text"
value={editingMethod()}
onInput={(e) => setEditingMethod(e.currentTarget.value)}
class="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="com.linspirer.method.name"
/>
}
>
<select
value={editingMethod()}
onChange={(e) => setEditingMethod(e.currentTarget.value)}
class="w-full border border-gray-300 rounded-md px-3 py-2"
>
<option value="">-- Select Method --</option>
<option value="com.linspirer.tactics.gettactics">Get Tactics</option>
<option value="com.linspirer.device.getcommand">Get Command</option>
</select>
</Show>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Action
</label>
<select
value={editingAction()}
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="replace">Replace (Custom response)</option>
</select>
</div>
<Show when={editingAction() === 'replace'}>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Custom Response (JSON)
</label>
<textarea
value={editingResponse()}
onInput={(e) => setEditingResponse(e.currentTarget.value)}
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": {...}}'
/>
</div>
</Show>
<button
onClick={saveEdit}
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
>
{editingId() !== null ? 'Update Rule' : 'Create Rule'}
</button>
</div>
</div>
</Show>
<div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Method Name
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Action
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<Show when={!rules.loading} fallback={
<tr><td colspan="4" class="text-center py-4">Loading...</td></tr>
}>
<For each={rules()}>
{(rule) => (
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{rule.method_name}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span class={`px-2 py-1 rounded-full text-xs ${
rule.action === 'replace' ? 'bg-purple-100 text-purple-800' :
rule.action === 'modify' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{rule.action}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
onClick={() => toggleRule(rule)}
class={`px-3 py-1 rounded-md text-sm font-medium ${
rule.is_enabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{rule.is_enabled ? '✓ Enabled' : '✗ Disabled'}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => startEdit(rule)}
class="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</button>
<button
onClick={() => deleteRule(rule.id)}
class="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
);
};
export default RulesList;

10
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import App from './App';
import './styles/index.css';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
render(() => <App />, root);

View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-100;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-400 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500;
}

View File

@@ -0,0 +1,36 @@
export interface InterceptionRule {
id: number;
method_name: string;
action: 'passthrough' | 'modify' | 'replace';
custom_response: any | null;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface Command {
id: number;
command: any;
status: 'unverified' | 'verified' | 'rejected';
received_at: string;
processed_at?: string;
notes?: string;
}
export interface CreateRuleRequest {
method_name: string;
action: string;
custom_response?: string;
}
export interface UpdateRuleRequest {
method_name?: string;
action?: string;
custom_response?: string;
is_enabled?: boolean;
}
export interface UpdateCommandRequest {
status: string;
notes?: string;
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"jsxImportSource": "solid-js",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src"]
}

21
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
export default defineConfig({
plugins: [solid()],
base: '/admin/',
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
minify: 'terser',
},
server: {
proxy: {
'/admin/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
});