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:
@@ -13,6 +13,8 @@ RUN bunx prisma generate
|
|||||||
|
|
||||||
# Build frontend (Vite → dist/)
|
# Build frontend (Vite → dist/)
|
||||||
FROM prisma AS builder
|
FROM prisma AS builder
|
||||||
|
ARG VITE_URL_API_DESA_PLUS
|
||||||
|
ENV VITE_URL_API_DESA_PLUS=$VITE_URL_API_DESA_PLUS
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "app_config" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "app_config_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
@@ -146,6 +146,14 @@ model BugLog {
|
|||||||
@@map("bug_log")
|
@@map("bug_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AppConfig {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("app_config")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
50
src/app.ts
50
src/app.ts
@@ -1552,4 +1552,54 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
|
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── App Config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.get('/api/admin/config', async ({ request, set }) => {
|
||||||
|
const auth = await requireDeveloper(request, set)
|
||||||
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||||
|
const configs = await prisma.appConfig.findMany({ orderBy: { key: 'asc' } })
|
||||||
|
return { configs: configs.map((c) => ({ key: c.key, value: c.value, updatedAt: c.updatedAt.toISOString() })) }
|
||||||
|
}, {
|
||||||
|
detail: { summary: 'Get App Config', tags: ['Admin'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
.put('/api/admin/config', async ({ request, set }) => {
|
||||||
|
const auth = await requireDeveloper(request, set)
|
||||||
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||||
|
const body = await request.json() as { key: string; value: string }
|
||||||
|
if (!body.key || typeof body.value !== 'string') { set.status = 400; return { error: 'key and value required' } }
|
||||||
|
const config = await prisma.appConfig.upsert({
|
||||||
|
where: { key: body.key },
|
||||||
|
update: { value: body.value },
|
||||||
|
create: { key: body.key, value: body.value },
|
||||||
|
})
|
||||||
|
await createSystemLog(auth.userId, 'UPDATE', `Updated app config: ${body.key}`)
|
||||||
|
return { key: config.key, value: config.value, updatedAt: config.updatedAt.toISOString() }
|
||||||
|
}, {
|
||||||
|
detail: { summary: 'Update App Config', tags: ['Admin'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Desa Plus Proxy ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.all('/api/proxy/desa-plus/*', async ({ request, set }) => {
|
||||||
|
const baseConfig = await prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } })
|
||||||
|
if (!baseConfig?.value) { set.status = 503; return { error: 'URL_API_DESA_PLUS belum dikonfigurasi. Set di /dev → Settings.' } }
|
||||||
|
const base = baseConfig.value.replace(/\/$/, '')
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const upstream = `${base}${url.pathname.replace('/api/proxy/desa-plus', '')}${url.search}`
|
||||||
|
const headers = new Headers(request.headers)
|
||||||
|
headers.delete('host')
|
||||||
|
try {
|
||||||
|
const res = await fetch(upstream, { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined })
|
||||||
|
const contentType = res.headers.get('content-type') ?? 'application/json'
|
||||||
|
set.status = res.status
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'content-type': contentType } })
|
||||||
|
} catch (e) {
|
||||||
|
set.status = 502
|
||||||
|
return { error: 'Gagal menghubungi API desa-plus', detail: String(e) }
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
detail: { summary: 'Proxy Desa Plus API', tags: ['Proxy'] },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
export const API_BASE_URL = import.meta.env.VITE_URL_API_DESA_PLUS
|
const DESA_PLUS_PROXY = '/api/proxy/desa-plus'
|
||||||
|
|
||||||
export const API_URLS = {
|
export const API_URLS = {
|
||||||
getVillages: (page: number, search: string) =>
|
getVillages: (page: number, search: string) =>
|
||||||
`${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`,
|
`${DESA_PLUS_PROXY}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
infoVillages: (id: string) =>
|
infoVillages: (id: string) =>
|
||||||
`${API_BASE_URL}/api/monitoring/info-villages?id=${id}`,
|
`${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`,
|
||||||
gridVillages: (id: string) =>
|
gridVillages: (id: string) =>
|
||||||
`${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`,
|
`${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`,
|
||||||
graphLogVillages: (id: string, time: string) =>
|
graphLogVillages: (id: string, time: string) =>
|
||||||
`${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
|
`${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
|
||||||
getUsers: (page: number, search: string) =>
|
getUsers: (page: number, search: string) =>
|
||||||
`${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
|
`${DESA_PLUS_PROXY}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
getLogsAllVillages: (page: number, search: string) =>
|
getLogsAllVillages: (page: number, search: string) =>
|
||||||
`${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
|
`${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`,
|
getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`,
|
||||||
getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`,
|
getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`,
|
||||||
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
|
getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`,
|
||||||
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
|
postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`,
|
||||||
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
|
createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`,
|
||||||
createUser: () => `${API_BASE_URL}/api/monitoring/create-user`,
|
createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`,
|
||||||
listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`,
|
listRole: () => `${DESA_PLUS_PROXY}/api/monitoring/list-userrole-villages`,
|
||||||
listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`,
|
listGroup: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-group-villages?id=${id}`,
|
||||||
listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`,
|
listPosition: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-position-villages?id=${id}`,
|
||||||
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
|
editUser: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-user`,
|
||||||
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
|
updateStatusVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/update-status-villages`,
|
||||||
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
|
editVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-villages`,
|
||||||
getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => {
|
getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => {
|
||||||
const params = new URLSearchParams({ page: String(page), search, type, userId })
|
const params = new URLSearchParams({ page: String(page), search, type, userId })
|
||||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const navItems = [
|
|||||||
// { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
|
// { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
|
||||||
{ label: 'Database', icon: TbDatabase, key: 'database' },
|
{ label: 'Database', icon: TbDatabase, key: 'database' },
|
||||||
{ label: 'Project', icon: TbSitemap, key: 'project' },
|
{ label: 'Project', icon: TbSitemap, key: 'project' },
|
||||||
// { label: 'Settings', icon: TbSettings, key: 'settings' },
|
{ label: 'Settings', icon: TbSettings, key: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function DevPage() {
|
function DevPage() {
|
||||||
@@ -1461,15 +1461,113 @@ function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Ed
|
|||||||
|
|
||||||
// ─── Settings Panel ────────────────────────────────────────────────────────────
|
// ─── 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() {
|
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 (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={3}>Settings</Title>
|
<Title order={3}>Settings</Title>
|
||||||
<Paper withBorder p="xl">
|
<Text size="sm" c="dimmed">Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy.</Text>
|
||||||
<Center>
|
|
||||||
<Text c="dimmed">Konfigurasi sistem akan ditampilkan di sini.</Text>
|
{isLoading ? <Center><Loader /></Center> : (
|
||||||
</Center>
|
<Stack gap="md">
|
||||||
</Paper>
|
{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>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user