diff --git a/Dockerfile b/Dockerfile index 923dcd9..90fbed2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ RUN bunx prisma generate # Build frontend (Vite → dist/) FROM prisma AS builder +ARG VITE_URL_API_DESA_PLUS +ENV VITE_URL_API_DESA_PLUS=$VITE_URL_API_DESA_PLUS COPY . . RUN bun run build diff --git a/prisma/migrations/20260429074454_add_app_config/migration.sql b/prisma/migrations/20260429074454_add_app_config/migration.sql new file mode 100644 index 0000000..5f1770b --- /dev/null +++ b/prisma/migrations/20260429074454_add_app_config/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 475e633..feb3cab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,14 @@ model BugLog { @@map("bug_log") } + +model AppConfig { + key String @id + value String + updatedAt DateTime @updatedAt + + @@map("app_config") +} diff --git a/src/app.ts b/src/app.ts index a5a55ad..5922574 100644 --- a/src/app.ts +++ b/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 } } }) + + // ─── 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'] }, + }) } diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 0845254..421aed0 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -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 = { - getVillages: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`, - infoVillages: (id: string) => - `${API_BASE_URL}/api/monitoring/info-villages?id=${id}`, - gridVillages: (id: string) => - `${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`, - graphLogVillages: (id: string, time: string) => - `${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, - getUsers: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, - getLogsAllVillages: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, - getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`, - getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`, - getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`, - postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`, - createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`, - createUser: () => `${API_BASE_URL}/api/monitoring/create-user`, - listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`, - listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`, - listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`, - editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, - updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`, - editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`, + getVillages: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`, + infoVillages: (id: string) => + `${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`, + gridVillages: (id: string) => + `${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`, + graphLogVillages: (id: string, time: string) => + `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, + getUsers: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, + getLogsAllVillages: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, + getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, + getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`, + getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`, + postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`, + createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`, + createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`, + listRole: () => `${DESA_PLUS_PROXY}/api/monitoring/list-userrole-villages`, + listGroup: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-group-villages?id=${id}`, + listPosition: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-position-villages?id=${id}`, + editUser: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-user`, + updateStatusVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/update-status-villages`, + editVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-villages`, getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => { const params = new URLSearchParams({ page: String(page), search, type, userId }) if (dateFrom) params.set('dateFrom', dateFrom) diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index cceceea..8e99dd6 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -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>({}) + const [saved, setSaved] = useState>({}) + + 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 = {} + 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 ( Settings - -
- Konfigurasi sistem akan ditampilkan di sini. -
-
+ Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy. + + {isLoading ?
: ( + + {CONFIG_DEFINITIONS.map((def) => { + const existing = configs.find((c) => c.key === def.key) + return ( + + + +
+ {def.label} + {def.key} +
+ {existing && ( + + Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')} + + )} +
+ {def.description} + + + setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} + placeholder={def.placeholder} + /> + + + + {!existing && ( + Belum dikonfigurasi — data tidak akan ter-load + )} +
+
+ ) + })} +
+ )}
) }