From 4e9d5964ae9e92b2e63ffb3c6b95105898ea4d52 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 30 Apr 2026 11:28:25 +0800 Subject: [PATCH] feat: merge url_api & api_key to App, add application settings page --- bun.lock | 8 +- package.json | 4 +- .../migration.sql | 15 + .../migration.sql | 1 + prisma/schema.prisma | 26 +- src/app.ts | 115 +++++--- src/frontend/routes/dev.tsx | 274 +++++++++++------- 7 files changed, 274 insertions(+), 169 deletions(-) create mode 100644 prisma/migrations/20260430025602_remove_app_config_add_url_api_to_app/migration.sql create mode 100644 prisma/migrations/20260430035000_add_active_to_app/migration.sql diff --git a/bun.lock b/bun.lock index c1d46c1..ca4b997 100644 --- a/bun.lock +++ b/bun.lock @@ -8,9 +8,9 @@ "@elysiajs/eden": "^1.4.9", "@elysiajs/html": "^1.4.0", "@elysiajs/swagger": "^1.3.1", - "@mantine/charts": "^9.0.0", + "@mantine/charts": "^8.3.0", "@mantine/core": "^8.3.18", - "@mantine/dates": "^9.1.1", + "@mantine/dates": "^8.3.0", "@mantine/hooks": "^8.3.18", "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", @@ -202,11 +202,11 @@ "@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="], - "@mantine/charts": ["@mantine/charts@9.0.0", "", { "peerDependencies": { "@mantine/core": "9.0.0", "@mantine/hooks": "9.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": ">=3.2.1" } }, "sha512-TnbjiT2tXZDAQWZrv/+Xu3JKYjPiTfO5jSIbcwnxZSVtLI+PIxA7zrSps+it/Nx3ch8GHpDizJ7UArC0UfmNkQ=="], + "@mantine/charts": ["@mantine/charts@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x", "recharts": ">=2.13.3" } }, "sha512-oudif3EUH7Nb9DPm0abAPxpFYDWWjR3k2S5ll0/CcB+pJzlhwaBG19QwpOJaRA6VAvAXDDKOXCO4mi9XCEN78g=="], "@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="], - "@mantine/dates": ["@mantine/dates@9.1.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "9.1.1", "@mantine/hooks": "9.1.1", "dayjs": ">=1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA=="], + "@mantine/dates": ["@mantine/dates@8.3.18", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw=="], "@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="], diff --git a/package.json b/package.json index 807f277..1f479e1 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "@elysiajs/eden": "^1.4.9", "@elysiajs/html": "^1.4.0", "@elysiajs/swagger": "^1.3.1", - "@mantine/charts": "^9.0.0", + "@mantine/charts": "^8.3.0", "@mantine/core": "^8.3.18", - "@mantine/dates": "^9.1.1", + "@mantine/dates": "^8.3.0", "@mantine/hooks": "^8.3.18", "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", diff --git a/prisma/migrations/20260430025602_remove_app_config_add_url_api_to_app/migration.sql b/prisma/migrations/20260430025602_remove_app_config_add_url_api_to_app/migration.sql new file mode 100644 index 0000000..d0a42f4 --- /dev/null +++ b/prisma/migrations/20260430025602_remove_app_config_add_url_api_to_app/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable: tambah urlApi dan apiKey ke App +ALTER TABLE "App" ADD COLUMN "urlApi" TEXT; +ALTER TABLE "App" ADD COLUMN "apiKey" TEXT; + +-- DataMigration: pindahkan nilai dari app_config ke App sebelum drop +UPDATE "App" +SET "urlApi" = (SELECT value FROM app_config WHERE key = 'URL_API_DESA_PLUS') +WHERE id = 'desa-plus'; + +UPDATE "App" +SET "apiKey" = (SELECT value FROM app_config WHERE key = 'API_KEY_DESA_PLUS') +WHERE id = 'desa-plus'; + +-- DropTable +DROP TABLE "app_config"; diff --git a/prisma/migrations/20260430035000_add_active_to_app/migration.sql b/prisma/migrations/20260430035000_add_active_to_app/migration.sql new file mode 100644 index 0000000..502e7c5 --- /dev/null +++ b/prisma/migrations/20260430035000_add_active_to_app/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "App" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index feb3cab..362b2d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,16 +72,18 @@ model Session { } model App { - id String @id @default(uuid()) - name String - version String? - minVersion String? - maintenance Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + name String + version String? + minVersion String? + maintenance Boolean @default(false) + active Boolean @default(true) + urlApi String? + apiKey String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt bugs Bug[] - } model Log { @@ -146,14 +148,6 @@ 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 8401f78..5d81372 100644 --- a/src/app.ts +++ b/src/app.ts @@ -352,7 +352,8 @@ export function createApp() { // ─── Apps API ────────────────────────────────────── .get('/api/apps', async ({ query }) => { const search = query.search || '' - const where: any = {} + const all = query.all === 'true' + const where: any = all ? {} : { active: true } if (search) { where.name = { contains: search, mode: 'insensitive' } } @@ -372,15 +373,18 @@ export function createApp() { status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active', errors: app.bugs.length, version: app.version ?? '-', + minVersion: app.minVersion, maintenance: app.maintenance, + active: app.active, + urlApi: app.urlApi, })) }, { query: t.Object({ - search: t.Optional(t.String({ description: 'Filter berdasarkan nama aplikasi' })), + search: t.Optional(t.String()), + all: t.Optional(t.String()), }), detail: { summary: 'List Apps', - description: 'Mengembalikan semua aplikasi yang dimonitor beserta status (active/warning/error), jumlah bug OPEN, versi, dan mode maintenance.', tags: ['Apps'], }, }) @@ -407,6 +411,7 @@ export function createApp() { version: app.version ?? '-', minVersion: app.minVersion, maintenance: app.maintenance, + urlApi: app.urlApi, totalBugs: app._count.bugs, } }, { @@ -420,6 +425,72 @@ export function createApp() { }, }) + .post('/api/apps', async ({ body, request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const { id, name, version, minVersion, maintenance, urlApi, apiKey } = body as any + if (!id || !name) { set.status = 400; return { error: 'id and name are required' } } + const existing = await prisma.app.findUnique({ where: { id } }) + if (existing) { set.status = 409; return { error: 'App with this ID already exists' } } + const app = await prisma.app.create({ + data: { id, name, version: version || null, minVersion: minVersion || null, maintenance: maintenance ?? false, urlApi: urlApi || null, apiKey: apiKey || null }, + }) + await createSystemLog(auth.userId, 'CREATE', `Created app: ${app.id}`) + return { id: app.id, name: app.name, version: app.version, minVersion: app.minVersion, maintenance: app.maintenance, urlApi: app.urlApi } + }, { + detail: { summary: 'Create App', tags: ['Apps'] }, + }) + + .patch('/api/apps/:appId', async ({ params: { appId }, body, request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const app = await prisma.app.findUnique({ where: { id: appId } }) + if (!app) { set.status = 404; return { error: 'App not found' } } + const { name, version, minVersion, maintenance, urlApi, apiKey } = body as any + const updated = await prisma.app.update({ + where: { id: appId }, + data: { + ...(name !== undefined && { name }), + ...(version !== undefined && { version: version || null }), + ...(minVersion !== undefined && { minVersion: minVersion || null }), + ...(maintenance !== undefined && { maintenance }), + ...(urlApi !== undefined && { urlApi: urlApi || null }), + ...(apiKey !== undefined && { apiKey: apiKey || null }), + }, + }) + await createSystemLog(auth.userId, 'UPDATE', `Updated app: ${appId}`) + return { id: updated.id, name: updated.name, version: updated.version, minVersion: updated.minVersion, maintenance: updated.maintenance, urlApi: updated.urlApi } + }, { + params: t.Object({ appId: t.String() }), + detail: { summary: 'Update App', tags: ['Apps'] }, + }) + + .delete('/api/apps/:appId', async ({ params: { appId }, request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const app = await prisma.app.findUnique({ where: { id: appId } }) + if (!app) { set.status = 404; return { error: 'App not found' } } + await prisma.app.update({ where: { id: appId }, data: { active: false } }) + await createSystemLog(auth.userId, 'UPDATE', `Deactivated app: ${appId}`) + return { success: true } + }, { + params: t.Object({ appId: t.String() }), + detail: { summary: 'Deactivate App', tags: ['Apps'] }, + }) + + .post('/api/apps/:appId/activate', async ({ params: { appId }, request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const app = await prisma.app.findUnique({ where: { id: appId } }) + if (!app) { set.status = 404; return { error: 'App not found' } } + await prisma.app.update({ where: { id: appId }, data: { active: true } }) + await createSystemLog(auth.userId, 'UPDATE', `Activated app: ${appId}`) + return { success: true } + }, { + params: t.Object({ appId: t.String() }), + detail: { summary: 'Activate App', tags: ['Apps'] }, + }) + // ─── Logs API ────────────────────────────────────── .get('/api/logs', async ({ query }) => { const page = Number(query.page) || 1 @@ -1553,47 +1624,17 @@ 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, apiKeyConfig] = await Promise.all([ - prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } }), - prisma.appConfig.findUnique({ where: { key: 'API_KEY_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 app = await prisma.app.findUnique({ where: { id: 'desa-plus' } }) + if (!app?.urlApi) { set.status = 503; return { error: 'urlApi belum dikonfigurasi untuk app desa-plus.' } } + const base = app.urlApi.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') - if (apiKeyConfig?.value) headers.set('X-API-Key', apiKeyConfig.value) + if (app.apiKey) headers.set('X-API-Key', app.apiKey) 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' diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index e4e2ccf..708d0c3 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -22,6 +22,7 @@ import { Stack, Table, Text, + TextInput, ThemeIcon, Title, Tooltip, @@ -1462,137 +1463,190 @@ 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; secret?: boolean }[] = [ - { - 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', - }, - { - key: 'API_KEY_DESA_PLUS', - label: 'API Key Desa Plus', - description: 'API key untuk autentikasi ke API Desa Plus. Dikirim otomatis sebagai header X-API-Key pada setiap request proxy.', - placeholder: 'your-secret-api-key', - secret: true, - }, -] +interface AppEntry { + id: string + name: string + urlApi: string | null + status: string + active: boolean +} function SettingsPanel() { const qc = useQueryClient() - const [values, setValues] = useState>({}) - const [saved, setSaved] = useState(false) const { data, isLoading } = useQuery({ - queryKey: ['admin', 'config'], - queryFn: () => fetch('/api/admin/config', { credentials: 'include' }).then((r) => r.json()), + queryKey: ['apps'], + queryFn: () => fetch('/api/apps?all=true', { credentials: 'include' }).then((r) => r.json()), }) + const apps: AppEntry[] = Array.isArray(data) ? data : [] - const configs: AppConfigEntry[] = data?.configs ?? [] + // ── Add App modal ── + const [addOpened, { open: openAdd, close: closeAdd }] = useDisclosure(false) + const [newApp, setNewApp] = useState({ id: '', name: '', urlApi: '', apiKey: '' }) - 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]) + // ── API Config modal ── + const [apiOpened, { open: openApi, close: closeApi }] = useDisclosure(false) + const [apiTarget, setApiTarget] = useState(null) + const [apiForm, setApiForm] = useState({ urlApi: '', apiKey: '' }) - const saveAllMutation = useMutation({ - mutationFn: async (vals: Record) => { - await Promise.all( - CONFIG_DEFINITIONS.map((def) => - fetch('/api/admin/config', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key: def.key, value: vals[def.key] ?? '' }), - }) - ) - ) - }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['admin', 'config'] }) - setSaved(true) - setTimeout(() => setSaved(false), 2000) - notifications.show({ color: 'green', title: 'Tersimpan', message: 'Konfigurasi berhasil disimpan.' }) - }, - onError: () => { - notifications.show({ color: 'red', title: 'Gagal', message: 'Terjadi kesalahan saat menyimpan konfigurasi.' }) + const openApiModal = (app: AppEntry) => { + setApiTarget(app) + setApiForm({ urlApi: app.urlApi ?? '', apiKey: '' }) + openApi() + } + + const createMutation = useMutation({ + mutationFn: (body: typeof newApp) => fetch('/api/apps', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then((r) => r.json()), + onSuccess: (res) => { + if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return } + qc.invalidateQueries({ queryKey: ['apps'] }) + closeAdd() + setNewApp({ id: '', name: '', urlApi: '', apiKey: '' }) + notifications.show({ color: 'green', title: 'Success', message: 'Application added successfully.' }) }, }) - const hasUnconfigured = CONFIG_DEFINITIONS.some((def) => !configs.find((c) => c.key === def.key)) + const apiMutation = useMutation({ + mutationFn: ({ id, body }: { id: string; body: { urlApi: string; apiKey?: string } }) => fetch(`/api/apps/${id}`, { + method: 'PATCH', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then((r) => r.json()), + onSuccess: (res) => { + if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return } + qc.invalidateQueries({ queryKey: ['apps'] }) + closeApi() + notifications.show({ color: 'green', title: 'Success', message: 'API configuration updated.' }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => fetch(`/api/apps/${id}`, { method: 'DELETE', credentials: 'include' }).then((r) => r.json()), + onSuccess: (res) => { + if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return } + qc.invalidateQueries({ queryKey: ['apps'] }) + notifications.show({ color: 'green', title: 'Success', message: 'Application deactivated.' }) + }, + }) + + const activateMutation = useMutation({ + mutationFn: (id: string) => fetch(`/api/apps/${id}/activate`, { method: 'POST', credentials: 'include' }).then((r) => r.json()), + onSuccess: (res) => { + if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return } + qc.invalidateQueries({ queryKey: ['apps'] }) + notifications.show({ color: 'green', title: 'Success', message: 'Application activated.' }) + }, + }) + + const confirmDeactivate = (app: AppEntry) => modals.openConfirmModal({ + title: 'Deactivate Application', + children: Are you sure you want to deactivate {app.name}? It will no longer appear in the monitoring list., + labels: { confirm: 'Deactivate', cancel: 'Cancel' }, + confirmProps: { color: 'red' }, + onConfirm: () => deleteMutation.mutate(app.id), + }) + return ( - Settings - Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy. + +
+ Application Settings + Manage the URL API and API Key for each application. +
+ +
- {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')} - : Belum dikonfigurasi - } -
- {def.description} - setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} - placeholder={def.placeholder} - /> -
- ) - })} - - {hasUnconfigured && ( - Beberapa konfigurasi belum diisi — data tidak akan ter-load sampai disimpan. - )} - - - - -
-
+ {isLoading ?
: apps.length === 0 ? ( +
No applications found. Click "Add App" to get started.
+ ) : ( + + {apps.map((app) => ( + + + + + + + + + {app.name} + {app.id} + {!app.active && Inactive} + + + {app.urlApi + ? {app.urlApi} + : URL API not set + } + + + + + {app.active ? ( + <> + + + + ) : ( + + )} + + + + ))} + )} + + {/* ── Add App Modal ── */} + + + setNewApp((p) => ({ ...p, id: e.target.value }))} required /> + setNewApp((p) => ({ ...p, name: e.target.value }))} required /> + setNewApp((p) => ({ ...p, urlApi: e.target.value }))} /> + setNewApp((p) => ({ ...p, apiKey: e.target.value }))} /> + + + + + + + + {/* ── API Config Modal ── */} + + + setApiForm((p) => ({ ...p, urlApi: e.target.value }))} /> + setApiForm((p) => ({ ...p, apiKey: e.target.value }))} /> + + + + + +
) } -// ─── Unused imports fix ──────────────────────────────────────────────────────── -// Box, Container, Card, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, ThemeIcon, Title, Tooltip — all used above -// TbDots is used in OperatorsPanel menu void TbFileText void TbCode void TbUser