Extension Platform: API server + React dashboard
- Python API server with Gitea-based build (no local files needed) - PostgreSQL on Coolify internal network - React + Vite + Tailwind dashboard - Endpoints: /publish, /deploy, /deploy-all, /api/* - Dockerfile: multi-stage (Node build + Python runtime)
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS dashboard-build
|
||||
WORKDIR /dashboard
|
||||
COPY dashboard/package*.json ./
|
||||
RUN npm ci
|
||||
COPY dashboard/ .
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-alpine
|
||||
RUN apk add --no-cache postgresql-client
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY server.py .
|
||||
COPY --from=dashboard-build /dashboard/dist /app/dashboard/dist
|
||||
ENV PORT=8080
|
||||
ENV DIST_DIR=/app/dashboard/dist
|
||||
EXPOSE 8080
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>extension-dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://192.168.10.202:9090/api/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /deploy {
|
||||
proxy_pass http://192.168.10.202:9090/deploy;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
Generated
+2863
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "extension-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,299 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
function useFetch(url) {
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetch(url).then(r => r.json()).then(d => { setData(d); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [url])
|
||||
useEffect(() => { reload() }, [reload])
|
||||
return { data, loading, reload }
|
||||
}
|
||||
|
||||
// --- Stats Bar ---
|
||||
function StatsBar() {
|
||||
const { data } = useFetch(`${API}/stats`)
|
||||
if (!data) return null
|
||||
const items = [
|
||||
{ label: 'הרחבות', value: data.total_extensions, color: 'bg-blue-500' },
|
||||
{ label: 'לקוחות', value: data.total_customers, color: 'bg-green-500' },
|
||||
{ label: 'רישיונות', value: data.total_licenses, color: 'bg-purple-500' },
|
||||
{ label: 'פריסות מוצלחות', value: data.successful_deploys, color: 'bg-emerald-500' },
|
||||
{ label: 'כשלונות', value: data.failed_deploys, color: 'bg-red-500' },
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-4 mb-8">
|
||||
{items.map(s => (
|
||||
<div key={s.label} className="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div className={`text-3xl font-bold ${s.color.replace('bg-', 'text-')}`}>{s.value}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Extensions Table ---
|
||||
function ExtensionsTab() {
|
||||
const { data: extensions } = useFetch(`${API}/extensions`)
|
||||
if (!extensions) return <Loader />
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-right">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">שם</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">Composer</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">גרסה אחרונה</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">לקוחות</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">גרסאות</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{extensions.map(e => (
|
||||
<tr key={e.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{e.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 font-mono">{e.composer_name}</td>
|
||||
<td className="px-4 py-3"><Badge>{e.latest_version || '—'}</Badge></td>
|
||||
<td className="px-4 py-3 text-center">{e.customer_count}</td>
|
||||
<td className="px-4 py-3 text-center">{e.version_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Customers Tab ---
|
||||
function CustomersTab() {
|
||||
const { data: customers, reload } = useFetch(`${API}/customers`)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
if (!customers) return <Loader />
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="w-1/2">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">לקוחות ({customers.length})</h3>
|
||||
<button onClick={() => setShowForm(!showForm)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 text-sm">
|
||||
+ לקוח חדש
|
||||
</button>
|
||||
</div>
|
||||
{showForm && <CustomerForm onDone={() => { setShowForm(false); reload() }} />}
|
||||
<div className="bg-white rounded-lg shadow divide-y">
|
||||
{customers.map(c => (
|
||||
<div key={c.id} onClick={() => setSelected(c.id)}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 ${selected === c.id ? 'bg-blue-50 border-r-4 border-blue-500' : ''}`}>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-sm text-gray-500">{c.espocrm_url}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{c.extension_count} הרחבות • {c.is_active ? '🟢 פעיל' : '🔴 לא פעיל'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
{selected && <CustomerDetail customerId={selected} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerForm({ onDone }) {
|
||||
const [form, setForm] = useState({ name: '', email: '', espocrm_url: '', espocrm_api_key: '' })
|
||||
const submit = async () => {
|
||||
await fetch(`${API}/customers`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form)
|
||||
})
|
||||
onDone()
|
||||
}
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4 space-y-3">
|
||||
<input placeholder="שם לקוח" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-right" />
|
||||
<input placeholder="אימייל" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-right" dir="ltr" />
|
||||
<input placeholder="EspoCRM URL" value={form.espocrm_url} onChange={e => setForm({ ...form, espocrm_url: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2" dir="ltr" />
|
||||
<input placeholder="Espo-Authorization (base64)" value={form.espocrm_api_key}
|
||||
onChange={e => setForm({ ...form, espocrm_api_key: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 font-mono text-sm" dir="ltr" />
|
||||
<button onClick={submit} className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 w-full">
|
||||
שמור
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerDetail({ customerId }) {
|
||||
const { data: customer, reload } = useFetch(`${API}/customers/${customerId}`)
|
||||
const { data: allExtensions } = useFetch(`${API}/extensions`)
|
||||
const [deploying, setDeploying] = useState(null)
|
||||
const [deployResult, setDeployResult] = useState(null)
|
||||
|
||||
if (!customer) return <Loader />
|
||||
|
||||
const assignedIds = new Set((customer.extensions || []).map(e => e.id))
|
||||
const available = (allExtensions || []).filter(e => !assignedIds.has(e.id))
|
||||
|
||||
const deploy = async (module, version) => {
|
||||
setDeploying(module)
|
||||
setDeployResult(null)
|
||||
try {
|
||||
const r = await fetch(`${API}/deploy`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer_id: customerId, module, version })
|
||||
})
|
||||
const result = await r.json()
|
||||
setDeployResult({ module, ...result })
|
||||
reload()
|
||||
} catch (e) {
|
||||
setDeployResult({ module, status: 'error', message: e.message })
|
||||
}
|
||||
setDeploying(null)
|
||||
}
|
||||
|
||||
const assign = async (extId) => {
|
||||
await fetch(`${API}/customers/${customerId}/extensions`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ extension_id: extId })
|
||||
})
|
||||
reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">{customer.name}</h3>
|
||||
<div className="text-sm text-gray-500 mb-4" dir="ltr">{customer.espocrm_url}</div>
|
||||
|
||||
{deployResult && (
|
||||
<div className={`p-3 rounded mb-4 text-sm ${deployResult.status === 'ok' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{deployResult.module}: {deployResult.status === 'ok' ? 'הותקן בהצלחה!' : `שגיאה: ${deployResult.message}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow divide-y">
|
||||
{(customer.extensions || []).map(e => (
|
||||
<div key={e.id} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{e.name}</span>
|
||||
<span className="text-sm text-gray-500 mr-2">
|
||||
{e.installed_version ? `v${e.installed_version}` : 'לא מותקן'}
|
||||
</span>
|
||||
{e.installed_version && e.latest_version && e.installed_version !== e.latest_version && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded mr-2">
|
||||
עדכון: v{e.latest_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => deploy(e.name, e.latest_version)}
|
||||
disabled={deploying === e.name}
|
||||
className="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600 disabled:opacity-50">
|
||||
{deploying === e.name ? '...' : 'דחוף'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">הוסף הרחבה:</h4>
|
||||
<select onChange={e => e.target.value && assign(parseInt(e.target.value))}
|
||||
className="border rounded px-3 py-2 w-full">
|
||||
<option value="">בחר הרחבה...</option>
|
||||
{available.map(e => <option key={e.id} value={e.id}>{e.name} v{e.latest_version}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Deployments Tab ---
|
||||
function DeploymentsTab() {
|
||||
const { data: deployments } = useFetch(`${API}/deployments?limit=50`)
|
||||
if (!deployments) return <Loader />
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-right">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">לקוח</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">הרחבה</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">גרסה</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">סטטוס</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">סוג</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-gray-500">זמן</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{deployments.map(d => (
|
||||
<tr key={d.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">{d.customer_name}</td>
|
||||
<td className="px-4 py-3 font-medium">{d.extension_name}</td>
|
||||
<td className="px-4 py-3"><Badge>{d.version}</Badge></td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs ${d.status === 'ok' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{d.status === 'ok' ? 'הצלחה' : 'שגיאה'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{d.trigger_type === 'auto' ? 'אוטומטי' : 'ידני'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400" dir="ltr">{d.started_at?.slice(0, 19)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Shared Components ---
|
||||
function Badge({ children }) {
|
||||
return <span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded text-sm font-mono">{children}</span>
|
||||
}
|
||||
|
||||
function Loader() {
|
||||
return <div className="text-center py-8 text-gray-400">טוען...</div>
|
||||
}
|
||||
|
||||
// --- Main App ---
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState('extensions')
|
||||
const tabs = [
|
||||
{ id: 'extensions', label: 'הרחבות' },
|
||||
{ id: 'customers', label: 'לקוחות' },
|
||||
{ id: 'deployments', label: 'פריסות' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100" dir="rtl">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Extension Platform</h1>
|
||||
<nav className="flex gap-2">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-4 py-2 rounded text-sm font-medium ${tab === t.id ? 'bg-blue-500 text-white' : 'text-gray-600 hover:bg-gray-100'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||
<StatsBar />
|
||||
{tab === 'extensions' && <ExtensionsTab />}
|
||||
{tab === 'customers' && <CustomersTab />}
|
||||
{tab === 'deployments' && <DeploymentsTab />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
psycopg2-binary==2.9.11
|
||||
@@ -0,0 +1,537 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extension Platform API Server.
|
||||
|
||||
Runs on Coolify. Connects to PostgreSQL via Docker internal network.
|
||||
Builds extensions from Gitea (no local files needed).
|
||||
|
||||
Pipeline endpoints (called by n8n):
|
||||
POST /publish — Download from Gitea, build ZIP, publish to Composer Registry + Release
|
||||
POST /deploy — Deploy extension to a specific customer via EspoCRM API
|
||||
POST /deploy-all — Deploy to all licensed customers
|
||||
|
||||
Dashboard API:
|
||||
GET /api/stats
|
||||
GET /api/extensions
|
||||
GET /api/extensions/:id/versions
|
||||
GET /api/customers
|
||||
POST /api/customers
|
||||
PUT /api/customers/:id
|
||||
GET /api/customers/:id
|
||||
POST /api/customers/:id/extensions
|
||||
DELETE /api/customers/:id/extensions/:ext_id
|
||||
GET /api/deployments
|
||||
"""
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import json, os, re, ssl, mimetypes, tempfile, shutil, zipfile, io
|
||||
import base64, urllib.request, urllib.error
|
||||
import psycopg2, psycopg2.extras
|
||||
|
||||
# --- Config from environment ---
|
||||
DB_HOST = os.environ.get('DB_HOST', 'pss404gosg8oo0o8k40o4wco')
|
||||
DB_PORT = int(os.environ.get('DB_PORT', '5432'))
|
||||
DB_NAME = os.environ.get('DB_NAME', 'extension_platform')
|
||||
DB_USER = os.environ.get('DB_USER', 'postgres')
|
||||
DB_PASS = os.environ.get('DB_PASS', '9cugHhKR8bCFXzm')
|
||||
|
||||
GITEA_URL = os.environ.get('GITEA_URL', 'https://gitea.dev.marcus-law.co.il')
|
||||
GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '90acb3ebc0f98edfb7a373ef9902bde1cc8eff96')
|
||||
GITEA_ORG = os.environ.get('GITEA_ORG', 'espocrm-extensions')
|
||||
|
||||
DIST_DIR = os.environ.get('DIST_DIR', '/app/dashboard/dist')
|
||||
|
||||
# Module name mapping
|
||||
MODULES = {
|
||||
"CrmAssistant": "crm-assistant", "DigitalSignature": "digital-signature",
|
||||
"EmailDefaults": "email-defaults", "GoogleIntegration": "google-integration",
|
||||
"GreenInvoiceBilling": "green-invoice-billing", "HebrewLanguage": "hebrew-language",
|
||||
"KlearBranding": "klear-branding", "LegalCrm": "legal-crm",
|
||||
"LicenseManager": "license-manager", "LocalDocuments": "local-documents",
|
||||
"MpdfEngine": "mpdf-engine", "NetHamishpat": "net-hamishpat",
|
||||
"NetworkStorageIntegration": "network-storage-integration",
|
||||
"NextCloudIntegration": "nextcloud-integration", "OfficeAssistant": "office-assistant",
|
||||
"PhoneIsraelFormat": "phone-israel-format", "SmartAssistant": "smart-assistant",
|
||||
"SmsIntegration": "sms-integration", "WhatsAppIntegration": "whatsapp-integration",
|
||||
}
|
||||
|
||||
SSL_CTX = ssl.create_default_context()
|
||||
SSL_CTX.check_hostname = False
|
||||
SSL_CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
# --- DB helpers ---
|
||||
def get_db():
|
||||
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS)
|
||||
|
||||
def query(sql, params=None, fetch='all'):
|
||||
conn = get_db()
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql, params)
|
||||
if fetch == 'all':
|
||||
result = [dict(r) for r in cur.fetchall()]
|
||||
elif fetch == 'one':
|
||||
row = cur.fetchone()
|
||||
result = dict(row) if row else None
|
||||
else:
|
||||
result = None
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
# --- Gitea helpers ---
|
||||
def gitea_api(method, path, data=None, raw=False):
|
||||
url = f"{GITEA_URL}/api/v1{path}"
|
||||
headers = {'Authorization': f'token {GITEA_TOKEN}'}
|
||||
if data and not raw:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
data = json.dumps(data).encode()
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
||||
return json.loads(resp.read()) if not raw else resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
return {'error': True, 'code': e.code, 'message': body[:300]}
|
||||
|
||||
|
||||
def gitea_upload(url, file_data, filename):
|
||||
"""Upload file to Gitea (Composer registry or release asset)."""
|
||||
headers = {'Authorization': f'token {GITEA_TOKEN}'}
|
||||
req = urllib.request.Request(url, data=file_data, headers=headers, method='PUT')
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
||||
return {'status': 'ok', 'code': resp.getcode()}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {'status': 'error', 'code': e.code, 'message': e.read().decode()[:200]}
|
||||
|
||||
|
||||
# --- Build logic (from Gitea, no local files) ---
|
||||
def build_and_publish(module, version):
|
||||
"""Download extension from Gitea, inject composer.json, publish to registry + release."""
|
||||
if module not in MODULES:
|
||||
return {'status': 'error', 'message': f'Unknown module: {module}'}
|
||||
|
||||
kebab = MODULES[module]
|
||||
tag = f'v{version}'
|
||||
composer_name = f'{GITEA_ORG}/{kebab}'
|
||||
|
||||
# 1. Download tag archive from Gitea
|
||||
archive_url = f"{GITEA_URL}/api/v1/repos/{GITEA_ORG}/{module}/archive/{tag}.zip"
|
||||
headers = {'Authorization': f'token {GITEA_TOKEN}'}
|
||||
req = urllib.request.Request(archive_url, headers=headers)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
||||
archive_data = resp.read()
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'step': 'download', 'message': str(e)}
|
||||
|
||||
# 2. Extract and read manifest.json
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
archive_path = os.path.join(tmpdir, 'archive.zip')
|
||||
with open(archive_path, 'wb') as f:
|
||||
f.write(archive_data)
|
||||
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
# Gitea archives have a top-level directory
|
||||
top_dirs = set(n.split('/')[0] for n in zf.namelist() if '/' in n)
|
||||
prefix = list(top_dirs)[0] + '/' if len(top_dirs) == 1 else ''
|
||||
|
||||
# Read manifest
|
||||
manifest_path = f'{prefix}manifest.json'
|
||||
try:
|
||||
manifest = json.loads(zf.read(manifest_path))
|
||||
except KeyError:
|
||||
return {'status': 'error', 'step': 'manifest', 'message': f'manifest.json not found (tried {manifest_path})'}
|
||||
|
||||
# Validate version
|
||||
if manifest.get('version') != version:
|
||||
return {'status': 'error', 'step': 'version',
|
||||
'message': f"Mismatch: tag={version}, manifest={manifest.get('version')}"}
|
||||
|
||||
# 3. Generate composer.json
|
||||
composer = {
|
||||
"name": composer_name,
|
||||
"description": manifest.get('description', ''),
|
||||
"version": version,
|
||||
"type": "espocrm-extension",
|
||||
"license": "proprietary",
|
||||
"authors": [{"name": manifest.get('author', 'klear')}],
|
||||
"require": {"php": manifest.get('php', ['>=8.1'])[0] if isinstance(manifest.get('php'), list) else '>=8.1'},
|
||||
"extra": {
|
||||
"espocrm-module": module,
|
||||
"espocrm-min-version": manifest.get('acceptableVersions', ['>=8.0.0'])[0] if isinstance(manifest.get('acceptableVersions'), list) else '>=8.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Build extension ZIP with composer.json at root
|
||||
ext_zip_path = os.path.join(tmpdir, f'{module}-{version}.zip')
|
||||
with zipfile.ZipFile(ext_zip_path, 'w', zipfile.ZIP_DEFLATED) as ext_zip:
|
||||
ext_zip.writestr('composer.json', json.dumps(composer, indent=2))
|
||||
|
||||
# Re-read archive and copy contents without the top-level dir
|
||||
with zipfile.ZipFile(archive_path) as src_zip:
|
||||
for item in src_zip.namelist():
|
||||
if item.endswith('/'):
|
||||
continue
|
||||
# Strip top-level directory
|
||||
rel_path = item[len(prefix):] if prefix and item.startswith(prefix) else item
|
||||
if not rel_path or rel_path.startswith('.git'):
|
||||
continue
|
||||
ext_zip.writestr(rel_path, src_zip.read(item))
|
||||
|
||||
with open(ext_zip_path, 'rb') as f:
|
||||
ext_zip_data = f.read()
|
||||
|
||||
# 5. Publish to Gitea Composer Registry
|
||||
registry_url = f"{GITEA_URL}/api/packages/{GITEA_ORG}/composer"
|
||||
reg_result = gitea_upload(registry_url, ext_zip_data, f'{module}-{version}.zip')
|
||||
reg_status = 'published' if reg_result.get('code') == 201 else 'exists' if reg_result.get('code') == 409 else f"error:{reg_result.get('code')}"
|
||||
|
||||
# 6. Create Gitea Release + upload asset
|
||||
release_result = gitea_api('POST', f'/repos/{GITEA_ORG}/{module}/releases', {
|
||||
'tag_name': tag, 'name': tag, 'body': f'Release {version}', 'draft': False, 'prerelease': False
|
||||
})
|
||||
release_id = release_result.get('id')
|
||||
asset_status = 'skipped'
|
||||
if release_id and not release_result.get('error'):
|
||||
# Upload asset using multipart
|
||||
boundary = '----FormBoundary7MA4YWxkTrZu0gW'
|
||||
body = (
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{module}-{version}.zip"\r\n'
|
||||
f'Content-Type: application/zip\r\n\r\n'
|
||||
).encode() + ext_zip_data + f'\r\n--{boundary}--\r\n'.encode()
|
||||
|
||||
asset_url = f"{GITEA_URL}/api/v1/repos/{GITEA_ORG}/{module}/releases/{release_id}/assets?name={module}-{version}.zip"
|
||||
headers = {
|
||||
'Authorization': f'token {GITEA_TOKEN}',
|
||||
'Content-Type': f'multipart/form-data; boundary={boundary}'
|
||||
}
|
||||
req = urllib.request.Request(asset_url, data=body, headers=headers, method='POST')
|
||||
try:
|
||||
urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
||||
asset_status = 'uploaded'
|
||||
except urllib.error.HTTPError as e:
|
||||
asset_status = f'error:{e.code}'
|
||||
elif release_result.get('error'):
|
||||
asset_status = f'release_failed:{release_result.get("message","")[:100]}'
|
||||
|
||||
# 7. Update DB
|
||||
db_ok = False
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""INSERT INTO extension_versions (extension_id, version, zip_filename, git_tag)
|
||||
SELECT id, %s, %s, %s FROM extensions WHERE name = %s
|
||||
ON CONFLICT (extension_id, version) DO NOTHING""",
|
||||
(version, f'{module}-{version}.zip', tag, module))
|
||||
cur.execute("UPDATE extensions SET latest_version = %s, updated_at = NOW() WHERE name = %s",
|
||||
(version, module))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
db_ok = True
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {
|
||||
'status': 'ok', 'module': module, 'version': version,
|
||||
'composer_name': composer_name,
|
||||
'registry': reg_status, 'release_id': release_id, 'asset': asset_status,
|
||||
'db_updated': db_ok, 'zip_size': len(ext_zip_data)
|
||||
}
|
||||
|
||||
|
||||
# --- Deploy logic ---
|
||||
def deploy_to_customer(customer, module, version):
|
||||
"""Deploy extension to a customer via EspoCRM API. Downloads ZIP from Gitea."""
|
||||
espo_url = customer['espocrm_url'].rstrip('/')
|
||||
api_key = customer['espocrm_api_key']
|
||||
|
||||
# Download ZIP from Gitea release
|
||||
tag = f'v{version}'
|
||||
releases = gitea_api('GET', f'/repos/{GITEA_ORG}/{module}/releases/tags/{tag}')
|
||||
if releases.get('error'):
|
||||
return {'status': 'error', 'step': 'find_release', 'message': releases.get('message','')}
|
||||
|
||||
zip_url = None
|
||||
for asset in releases.get('assets', []):
|
||||
if asset['name'].endswith('.zip'):
|
||||
zip_url = asset['browser_download_url']
|
||||
break
|
||||
|
||||
if not zip_url:
|
||||
return {'status': 'error', 'step': 'find_zip', 'message': 'No ZIP asset in release'}
|
||||
|
||||
# Download ZIP
|
||||
req = urllib.request.Request(zip_url, headers={'Authorization': f'token {GITEA_TOKEN}'})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
||||
zip_data = resp.read()
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'step': 'download_zip', 'message': str(e)}
|
||||
|
||||
zip_base64 = base64.b64encode(zip_data).decode()
|
||||
headers = {'Espo-Authorization': api_key, 'Content-Type': 'application/json'}
|
||||
|
||||
# Upload to EspoCRM
|
||||
upload_data = json.dumps({'file': f'data:application/zip;base64,{zip_base64}'}).encode()
|
||||
req = urllib.request.Request(f'{espo_url}/api/v1/Extension/action/upload',
|
||||
data=upload_data, headers=headers, method='POST')
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
||||
ext_id = json.loads(resp.read()).get('id')
|
||||
except urllib.error.HTTPError as e:
|
||||
return {'status': 'error', 'step': 'upload', 'code': e.code, 'message': e.read().decode()[:200]}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'step': 'upload', 'message': str(e)}
|
||||
|
||||
# Install
|
||||
req2 = urllib.request.Request(f'{espo_url}/api/v1/Extension/action/install',
|
||||
data=json.dumps({'id': ext_id}).encode(), headers=headers, method='POST')
|
||||
try:
|
||||
urllib.request.urlopen(req2, context=SSL_CTX, timeout=120)
|
||||
except urllib.error.HTTPError as e:
|
||||
return {'status': 'error', 'step': 'install', 'code': e.code, 'message': e.read().decode()[:200]}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'step': 'install', 'message': str(e)}
|
||||
|
||||
# Rebuild
|
||||
req3 = urllib.request.Request(f'{espo_url}/api/v1/Admin/rebuild',
|
||||
data=b'true', headers=headers, method='POST')
|
||||
try:
|
||||
urllib.request.urlopen(req3, context=SSL_CTX, timeout=60)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {'status': 'ok', 'ext_id': ext_id}
|
||||
|
||||
|
||||
# --- HTTP Handler ---
|
||||
class APIHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?')[0]
|
||||
if path == '/api/extensions':
|
||||
self._get_extensions()
|
||||
elif re.match(r'/api/extensions/\d+/versions', path):
|
||||
self._get_extension_versions(int(re.search(r'/api/extensions/(\d+)/versions', path).group(1)))
|
||||
elif path == '/api/customers':
|
||||
self._get_customers()
|
||||
elif re.match(r'/api/customers/\d+$', path):
|
||||
self._get_customer(int(re.search(r'/api/customers/(\d+)', path).group(1)))
|
||||
elif path == '/api/deployments':
|
||||
self._get_deployments()
|
||||
elif path == '/api/stats':
|
||||
self._get_stats()
|
||||
else:
|
||||
self._serve_static(path)
|
||||
|
||||
def do_POST(self):
|
||||
body = self._read_body()
|
||||
path = self.path.split('?')[0]
|
||||
if path == '/publish':
|
||||
self._handle_publish(body)
|
||||
elif path == '/deploy':
|
||||
self._handle_deploy(body)
|
||||
elif path == '/deploy-all':
|
||||
self._handle_deploy_all(body)
|
||||
elif path == '/api/customers':
|
||||
self._create_customer(body)
|
||||
elif re.match(r'/api/customers/\d+/extensions', path):
|
||||
self._assign_extension(int(re.search(r'/api/customers/(\d+)/extensions', path).group(1)), body)
|
||||
elif path == '/api/deploy':
|
||||
self._handle_deploy(body)
|
||||
else:
|
||||
self._respond(404, {'error': 'Not found'})
|
||||
|
||||
def do_PUT(self):
|
||||
body = self._read_body()
|
||||
path = self.path.split('?')[0]
|
||||
if re.match(r'/api/customers/\d+$', path):
|
||||
self._update_customer(int(re.search(r'/api/customers/(\d+)', path).group(1)), body)
|
||||
else:
|
||||
self._respond(404, {'error': 'Not found'})
|
||||
|
||||
def do_DELETE(self):
|
||||
path = self.path.split('?')[0]
|
||||
if re.match(r'/api/customers/\d+/extensions/\d+$', path):
|
||||
m = re.search(r'/api/customers/(\d+)/extensions/(\d+)', path)
|
||||
self._remove_extension(int(m.group(1)), int(m.group(2)))
|
||||
else:
|
||||
self._respond(404, {'error': 'Not found'})
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
# --- Dashboard API ---
|
||||
def _get_extensions(self):
|
||||
self._respond(200, query("""
|
||||
SELECT e.*, (SELECT COUNT(*) FROM extension_versions ev WHERE ev.extension_id = e.id) as version_count,
|
||||
(SELECT COUNT(*) FROM customer_extensions ce WHERE ce.extension_id = e.id AND ce.license_status = 'active') as customer_count
|
||||
FROM extensions e ORDER BY e.name"""))
|
||||
|
||||
def _get_extension_versions(self, ext_id):
|
||||
self._respond(200, query("SELECT * FROM extension_versions WHERE extension_id = %s ORDER BY released_at DESC", (ext_id,)))
|
||||
|
||||
def _get_customers(self):
|
||||
rows = query("""SELECT c.id, c.name, c.email, c.espocrm_url, c.is_active, c.notes, c.created_at, c.updated_at,
|
||||
c.coolify_url, c.coolify_app_uuid,
|
||||
(SELECT COUNT(*) FROM customer_extensions ce WHERE ce.customer_id = c.id AND ce.license_status = 'active') as extension_count,
|
||||
(SELECT MAX(dl.started_at) FROM deployment_log dl WHERE dl.customer_id = c.id) as last_deploy
|
||||
FROM customers c ORDER BY c.name""")
|
||||
self._respond(200, rows)
|
||||
|
||||
def _get_customer(self, cid):
|
||||
customer = query("SELECT id, name, email, espocrm_url, is_active, notes, coolify_url, coolify_app_uuid, created_at, updated_at FROM customers WHERE id = %s", (cid,), fetch='one')
|
||||
if not customer:
|
||||
self._respond(404, {'error': 'Not found'}); return
|
||||
customer['extensions'] = query("""
|
||||
SELECT e.id, e.name, e.composer_name, e.latest_version, ce.installed_version, ce.license_status, ce.auto_update, ce.installed_at
|
||||
FROM customer_extensions ce JOIN extensions e ON ce.extension_id = e.id WHERE ce.customer_id = %s ORDER BY e.name""", (cid,))
|
||||
self._respond(200, customer)
|
||||
|
||||
def _create_customer(self, body):
|
||||
for f in ['name', 'espocrm_url', 'espocrm_api_key']:
|
||||
if not body.get(f):
|
||||
self._respond(400, {'error': f'{f} required'}); return
|
||||
row = query("""INSERT INTO customers (name, email, espocrm_url, espocrm_api_key, coolify_url, coolify_token, coolify_app_uuid, notes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
|
||||
(body['name'], body.get('email'), body['espocrm_url'], body['espocrm_api_key'],
|
||||
body.get('coolify_url'), body.get('coolify_token'), body.get('coolify_app_uuid'), body.get('notes')), fetch='one')
|
||||
self._respond(201, {'id': row['id'], 'name': body['name']})
|
||||
|
||||
def _update_customer(self, cid, body):
|
||||
fields, values = [], []
|
||||
for col in ['name','email','espocrm_url','espocrm_api_key','coolify_url','coolify_token','coolify_app_uuid','is_active','notes']:
|
||||
if col in body:
|
||||
fields.append(f"{col} = %s"); values.append(body[col])
|
||||
if not fields:
|
||||
self._respond(400, {'error': 'No fields'}); return
|
||||
fields.append("updated_at = NOW()"); values.append(cid)
|
||||
query(f"UPDATE customers SET {', '.join(fields)} WHERE id = %s", values, fetch=None)
|
||||
self._respond(200, {'status': 'ok'})
|
||||
|
||||
def _assign_extension(self, cid, body):
|
||||
if not body.get('extension_id'):
|
||||
self._respond(400, {'error': 'extension_id required'}); return
|
||||
query("""INSERT INTO customer_extensions (customer_id, extension_id, auto_update, license_status)
|
||||
VALUES (%s, %s, %s, %s) ON CONFLICT (customer_id, extension_id) DO UPDATE
|
||||
SET license_status = EXCLUDED.license_status, auto_update = EXCLUDED.auto_update, updated_at = NOW()""",
|
||||
(cid, body['extension_id'], body.get('auto_update', True), body.get('license_status', 'active')), fetch=None)
|
||||
self._respond(201, {'status': 'ok'})
|
||||
|
||||
def _remove_extension(self, cid, ext_id):
|
||||
query("DELETE FROM customer_extensions WHERE customer_id = %s AND extension_id = %s", (cid, ext_id), fetch=None)
|
||||
self._respond(200, {'status': 'ok'})
|
||||
|
||||
def _get_deployments(self):
|
||||
qs = self.path.split('?')[1] if '?' in self.path else ''
|
||||
params = dict(p.split('=') for p in qs.split('&') if '=' in p) if qs else {}
|
||||
limit = min(int(params.get('limit', 50)), 200)
|
||||
self._respond(200, query("""SELECT dl.*, c.name as customer_name, e.name as extension_name
|
||||
FROM deployment_log dl JOIN customers c ON dl.customer_id = c.id JOIN extensions e ON dl.extension_id = e.id
|
||||
ORDER BY dl.started_at DESC LIMIT %s""", (limit,)))
|
||||
|
||||
def _get_stats(self):
|
||||
self._respond(200, query("""SELECT
|
||||
(SELECT COUNT(*) FROM extensions WHERE is_active) as total_extensions,
|
||||
(SELECT COUNT(*) FROM customers WHERE is_active) as total_customers,
|
||||
(SELECT COUNT(*) FROM customer_extensions WHERE license_status = 'active') as total_licenses,
|
||||
(SELECT COUNT(*) FROM deployment_log WHERE status = 'ok') as successful_deploys,
|
||||
(SELECT COUNT(*) FROM deployment_log WHERE status != 'ok') as failed_deploys,
|
||||
(SELECT MAX(started_at) FROM deployment_log) as last_deploy""", fetch='one'))
|
||||
|
||||
# --- Pipeline ---
|
||||
def _handle_publish(self, body):
|
||||
module, version = body.get('module',''), body.get('version','')
|
||||
if not module or not version:
|
||||
self._respond(400, {'error': 'module and version required'}); return
|
||||
result = build_and_publish(module, version)
|
||||
self._respond(200, result)
|
||||
|
||||
def _handle_deploy(self, body):
|
||||
cid, module, version = body.get('customer_id'), body.get('module',''), body.get('version','')
|
||||
if not all([cid, module, version]):
|
||||
self._respond(400, {'error': 'customer_id, module, version required'}); return
|
||||
customer = query("SELECT id, name, espocrm_url, espocrm_api_key FROM customers WHERE id = %s AND is_active", (cid,), fetch='one')
|
||||
if not customer:
|
||||
self._respond(404, {'error': 'Customer not found'}); return
|
||||
result = deploy_to_customer(customer, module, version)
|
||||
# Log
|
||||
query("""INSERT INTO deployment_log (customer_id, extension_id, version, status, trigger_type, error_message)
|
||||
SELECT %s, e.id, %s, %s, 'manual', %s FROM extensions e WHERE e.name = %s""",
|
||||
(cid, version, result['status'], result.get('message',''), module), fetch=None)
|
||||
if result['status'] == 'ok':
|
||||
query("""UPDATE customer_extensions SET installed_version = %s, installed_at = NOW()
|
||||
FROM extensions e WHERE customer_extensions.extension_id = e.id
|
||||
AND customer_extensions.customer_id = %s AND e.name = %s""", (version, cid, module), fetch=None)
|
||||
self._respond(200, {**result, 'customer': customer['name'], 'module': module, 'version': version})
|
||||
|
||||
def _handle_deploy_all(self, body):
|
||||
module, version = body.get('module',''), body.get('version','')
|
||||
if not module or not version:
|
||||
self._respond(400, {'error': 'module and version required'}); return
|
||||
customers = query("""SELECT c.id, c.name, c.espocrm_url, c.espocrm_api_key FROM customers c
|
||||
JOIN customer_extensions ce ON c.id = ce.customer_id JOIN extensions e ON ce.extension_id = e.id
|
||||
WHERE e.name = %s AND ce.auto_update AND ce.license_status = 'active' AND c.is_active""", (module,))
|
||||
results = []
|
||||
for c in customers:
|
||||
r = deploy_to_customer(c, module, version)
|
||||
results.append({**r, 'customer': c['name']})
|
||||
query("""INSERT INTO deployment_log (customer_id, extension_id, version, status, trigger_type, error_message)
|
||||
SELECT %s, e.id, %s, %s, 'auto', %s FROM extensions e WHERE e.name = %s""",
|
||||
(c['id'], version, r['status'], r.get('message',''), module), fetch=None)
|
||||
if r['status'] == 'ok':
|
||||
query("""UPDATE customer_extensions SET installed_version = %s, installed_at = NOW()
|
||||
FROM extensions e WHERE customer_extensions.extension_id = e.id
|
||||
AND customer_extensions.customer_id = %s AND e.name = %s""", (version, c['id'], module), fetch=None)
|
||||
self._respond(200, {'status': 'ok', 'module': module, 'version': version,
|
||||
'deployed_to': len(results), 'results': results})
|
||||
|
||||
# --- Helpers ---
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
return json.loads(self.rfile.read(length)) if length > 0 else {}
|
||||
|
||||
def _serve_static(self, path):
|
||||
if path == '/':
|
||||
path = '/index.html'
|
||||
file_path = os.path.join(DIST_DIR, path.lstrip('/'))
|
||||
if not os.path.isfile(file_path):
|
||||
file_path = os.path.join(DIST_DIR, 'index.html')
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', mimetypes.guess_type(file_path)[0] or 'application/octet-stream')
|
||||
self.send_header('Content-Length', len(content))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
except:
|
||||
self._respond(404, {'error': 'Not found'})
|
||||
|
||||
def _respond(self, code, data):
|
||||
self.send_response(code)
|
||||
self._cors_headers()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, default=str).encode())
|
||||
|
||||
def _cors_headers(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[api] {args[0]}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', '8080'))
|
||||
server = HTTPServer(('0.0.0.0', port), APIHandler)
|
||||
print(f"Extension Platform API on port {port}")
|
||||
server.serve_forever()
|
||||
Reference in New Issue
Block a user