feat: runtime config via DB — ganti VITE_URL_API_DESA_PLUS dengan proxy
- Tambah model AppConfig (key-value) ke schema + migration - Tambah GET/PUT /api/admin/config (DEVELOPER only) - Tambah proxy /api/proxy/desa-plus/* yang baca URL dari DB - Hapus VITE_URL_API_DESA_PLUS dari frontend, ganti semua URL desa-plus ke relative proxy path - Aktifkan Settings tab di /dev dengan UI untuk set URL_API_DESA_PLUS URL desa-plus kini bisa diubah via /dev → Settings tanpa rebuild image. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -113,7 +113,7 @@ const navItems = [
|
||||
// { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
|
||||
{ label: 'Database', icon: TbDatabase, key: 'database' },
|
||||
{ label: 'Project', icon: TbSitemap, key: 'project' },
|
||||
// { label: 'Settings', icon: TbSettings, key: 'settings' },
|
||||
{ label: 'Settings', icon: TbSettings, key: 'settings' },
|
||||
]
|
||||
|
||||
function DevPage() {
|
||||
@@ -1461,15 +1461,113 @@ function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Ed
|
||||
|
||||
// ─── Settings Panel ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AppConfigEntry { key: string; value: string; updatedAt: string }
|
||||
|
||||
const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string }[] = [
|
||||
{
|
||||
key: 'URL_API_DESA_PLUS',
|
||||
label: 'URL API Desa Plus',
|
||||
description: 'Base URL untuk API eksternal Desa Plus. Semua request dari frontend akan diproxy melalui server ke URL ini.',
|
||||
placeholder: 'https://api.desa-plus.example.com',
|
||||
},
|
||||
]
|
||||
|
||||
function SettingsPanel() {
|
||||
const qc = useQueryClient()
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState<Record<string, boolean>>({})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'config'],
|
||||
queryFn: () => fetch('/api/admin/config', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
|
||||
const configs: AppConfigEntry[] = data?.configs ?? []
|
||||
|
||||
useEffect(() => {
|
||||
const initial: Record<string, string> = {}
|
||||
for (const def of CONFIG_DEFINITIONS) {
|
||||
const existing = configs.find((c) => c.key === def.key)
|
||||
initial[def.key] = existing?.value ?? ''
|
||||
}
|
||||
setValues(initial)
|
||||
}, [configs])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
}).then((r) => r.json()),
|
||||
onSuccess: (_, { key }) => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'config'] })
|
||||
setSaved((prev) => ({ ...prev, [key]: true }))
|
||||
setTimeout(() => setSaved((prev) => ({ ...prev, [key]: false })), 2000)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={3}>Settings</Title>
|
||||
<Paper withBorder p="xl">
|
||||
<Center>
|
||||
<Text c="dimmed">Konfigurasi sistem akan ditampilkan di sini.</Text>
|
||||
</Center>
|
||||
</Paper>
|
||||
<Text size="sm" c="dimmed">Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy.</Text>
|
||||
|
||||
{isLoading ? <Center><Loader /></Center> : (
|
||||
<Stack gap="md">
|
||||
{CONFIG_DEFINITIONS.map((def) => {
|
||||
const existing = configs.find((c) => c.key === def.key)
|
||||
return (
|
||||
<Paper key={def.key} withBorder p="lg" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600} size="sm">{def.label}</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">{def.key}</Text>
|
||||
</div>
|
||||
{existing && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{def.description}</Text>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<input
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
value={values[def.key] ?? ''}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [def.key]: e.target.value }))}
|
||||
placeholder={def.placeholder}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="sm"
|
||||
color={saved[def.key] ? 'green' : 'blue'}
|
||||
loading={saveMutation.isPending && saveMutation.variables?.key === def.key}
|
||||
onClick={() => saveMutation.mutate({ key: def.key, value: values[def.key] ?? '' })}
|
||||
>
|
||||
{saved[def.key] ? 'Tersimpan' : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
{!existing && (
|
||||
<Badge color="orange" variant="light" size="xs">Belum dikonfigurasi — data tidak akan ter-load</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user