feat: implement MC domain filter proxy, API, dashboard

- proxy: asyncio TCP proxy with handshake parser, domain whitelist,
  transparent backend tunneling, SQLite logging, mtime hot reload
- api: FastAPI routes for config/domains/logs/status + restart trigger
- frontend: React + Vite NPM-style dashboard (dashboard/domains/logs/settings)
- nginx: reverse proxy for /api -> api:8000 and / -> frontend:3000
- docker-compose: full stack with shared data volume
- replace spec mc-domain-filter.md with README.md
This commit is contained in:
2026-05-20 16:39:18 +09:00
parent b45e884633
commit d10dae5cb9
33 changed files with 1872 additions and 223 deletions

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
export default function Domains() {
const [domains, setDomains] = useState([])
const [newDomain, setNewDomain] = useState('')
const [newNote, setNewNote] = useState('')
const [error, setError] = useState(null)
async function load() {
try {
setDomains(await api.domains())
setError(null)
} catch (e) {
setError(e.message)
}
}
useEffect(() => {
load()
}, [])
async function add(e) {
e.preventDefault()
if (!newDomain.trim()) return
try {
await api.addDomain({ domain: newDomain.trim(), enabled: true, note: newNote })
setNewDomain('')
setNewNote('')
setError(null)
await load()
} catch (e) {
setError(e.message)
}
}
async function toggle(d) {
try {
await api.patchDomain(d.domain, { enabled: !d.enabled })
await load()
} catch (e) {
setError(e.message)
}
}
async function remove(d) {
if (!confirm(`${d.domain} 을(를) 삭제할까요?`)) return
try {
await api.deleteDomain(d.domain)
await load()
} catch (e) {
setError(e.message)
}
}
return (
<div>
<h1>허용 도메인</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={add} className="form-row card">
<input
placeholder="mc.example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
/>
<input
placeholder="메모 (선택)"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
/>
<button type="submit">추가</button>
</form>
<table className="table">
<thead>
<tr>
<th>도메인</th>
<th>메모</th>
<th style={{ width: 80 }}>활성</th>
<th style={{ width: 90 }}></th>
</tr>
</thead>
<tbody>
{domains.length === 0 && (
<tr>
<td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 24 }}>
등록된 도메인이 없습니다.
</td>
</tr>
)}
{domains.map((d) => (
<tr key={d.domain}>
<td>
<code>{d.domain}</code>
</td>
<td>{d.note}</td>
<td>
<label className="switch">
<input
type="checkbox"
checked={d.enabled}
onChange={() => toggle(d)}
/>
<span />
</label>
</td>
<td>
<button className="danger" onClick={() => remove(d)}>삭제</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}