Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ GET /workflows?source=local&q=slack

See [docs/CATALOG.md](https://github.com/jentic/jentic-mini/blob/main/docs/CATALOG.md) for full details.

## Updating

The default `compose.yml` includes [Watchtower](https://containrrr.dev/watchtower/) in monitor-only mode — it checks for new images daily but **never applies updates automatically**. When a new version is available, the UI sidebar shows an "Update now" button. Click it and the update is applied (the app restarts briefly).

**Without Watchtower** (or if you prefer the command line):

```bash
./jentic-update.sh
```

Or manually:

```bash
docker compose pull jentic-mini && docker compose up -d jentic-mini
```

Database migrations run automatically on startup via Alembic, so your data is preserved across updates.

To disable the update check entirely, set `JENTIC_TELEMETRY=off`.

## Development

Prerequisites for local development without Docker:
Expand Down
20 changes: 20 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,23 @@ services:
# The built-in defaults (10/8, 172.16/12, 192.168/16, 127/8, ::1) always apply.
JENTIC_TRUSTED_SUBNETS: ${JENTIC_TRUSTED_SUBNETS:-}
LOG_LEVEL: ${LOG_LEVEL:-info}
WATCHTOWER_API_URL: http://jentic-watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-jentic-update}
labels:
com.centurylinklabs.watchtower.scope: "jentic"

watchtower:
image: containrrr/watchtower:latest
container_name: jentic-watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_POLL_INTERVAL: "86400"
WATCHTOWER_SCOPE: "jentic"
WATCHTOWER_MONITOR_ONLY: "true"
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:-jentic-update}
labels:
com.centurylinklabs.watchtower.scope: "jentic"
10 changes: 10 additions & 0 deletions jentic-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/bash
# Manual upgrade script for Jentic Mini.
# Run this on the host if Watchtower is not configured.
set -e
cd "$(dirname "$0")"
echo "Pulling latest Jentic Mini image..."
docker compose pull jentic-mini
echo "Restarting..."
docker compose up -d jentic-mini
echo "Jentic Mini updated successfully."
38 changes: 38 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,47 @@ async def get_version():
"current": APP_VERSION,
"latest": _version_cache["latest"],
"release_url": _version_cache["release_url"],
"upgrade_available": bool(os.getenv("WATCHTOWER_API_URL")),
}


@app.post("/admin/upgrade", tags=["meta"], include_in_schema=False)
async def trigger_upgrade(request: Request):
"""Trigger a one-click upgrade via Watchtower's HTTP API.

Requires a human session. Watchtower must be running in monitor-only
+ HTTP API mode (the default compose.yml configuration).
"""
if not getattr(request.state, "is_human_session", False):
from fastapi import HTTPException
raise HTTPException(403, "Upgrade requires a human session.")

watchtower_url = os.getenv("WATCHTOWER_API_URL")
if not watchtower_url:
from fastapi import HTTPException
raise HTTPException(
501,
"Watchtower is not configured. Set WATCHTOWER_API_URL or use "
"the manual upgrade command: docker compose pull && docker compose up -d",
)

watchtower_token = os.getenv("WATCHTOWER_TOKEN", "jentic-update")
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{watchtower_url}/v1/update",
headers={"Authorization": f"Bearer {watchtower_token}"},
timeout=10.0,
)
return {
"status": "update_triggered",
"message": "Watchtower is pulling the latest image. The app will restart shortly.",
}
except Exception as e:
from fastapi import HTTPException
raise HTTPException(502, f"Could not reach Watchtower: {e}")


@app.get("/favicon.ico", include_in_schema=False)
@app.get("/favicon.png", include_in_schema=False)
async def favicon():
Expand Down
48 changes: 37 additions & 11 deletions ui/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ function NavLink({
}

function SidebarContents({ onClose }: { onClose?: () => void }) {
const { updateAvailable, latestVersion, releaseUrl } = useUpdateCheck()
const { updateAvailable, currentVersion, latestVersion, releaseUrl, upgradeAvailable } = useUpdateCheck()
const [upgrading, setUpgrading] = useState(false)
return (
<aside className="w-60 bg-muted border-r border-border flex flex-col h-full">
<div className="h-16 flex items-center px-6 border-b border-border shrink-0">
Expand Down Expand Up @@ -81,16 +82,36 @@ function SidebarContents({ onClose }: { onClose?: () => void }) {
</nav>

<div className="px-3 py-3 border-t border-border shrink-0">
{updateAvailable && releaseUrl && (
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 mb-1 rounded-md text-xs font-semibold text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse shrink-0" />
Update available: {latestVersion}
</a>
{updateAvailable && (
<div className="px-4 py-2 mb-1 rounded-md text-xs bg-amber-50 dark:bg-amber-950/40">
<div className="flex items-center gap-2 font-semibold text-amber-600 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse shrink-0" />
Update available: {latestVersion}
</div>
{upgradeAvailable ? (
<button
onClick={async () => {
setUpgrading(true)
try {
await fetch('/admin/upgrade', { method: 'POST' })
} catch { /* app will go offline during restart */ }
}}
disabled={upgrading}
className="mt-1.5 w-full px-2 py-1 rounded text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 transition-colors"
>
{upgrading ? 'Updating...' : 'Update now'}
</button>
) : releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="block mt-1 text-[10px] text-amber-600/70 dark:text-amber-400/70 hover:underline"
>
View release notes
</a>
) : null}
</div>
)}
<a
href="/docs"
Expand All @@ -112,6 +133,11 @@ function SidebarContents({ onClose }: { onClose?: () => void }) {
<ExternalLink className="h-3 w-3 shrink-0" />
More at jentic.com
</a>
{currentVersion && (
<div className="px-4 pt-2 text-[10px] font-mono text-muted-foreground/50">
v{currentVersion}
</div>
)}
</div>
</aside>
)
Expand Down
41 changes: 27 additions & 14 deletions ui/src/hooks/useUpdateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface UpdateStatus {
latestVersion: string | null
updateAvailable: boolean
releaseUrl: string | null
upgradeAvailable: boolean
}

function parseSemver(v: string): number[] {
Expand All @@ -26,30 +27,20 @@ function isNewer(latest: string, current: string): boolean {
return false
}

const CACHE_KEY = 'jentic_update_check'

export function useUpdateCheck(): UpdateStatus {
const [status, setStatus] = useState<UpdateStatus>({
currentVersion: null,
latestVersion: null,
updateAvailable: false,
releaseUrl: null,
upgradeAvailable: false,
})

useEffect(() => {
// Only check once per session
const cached = sessionStorage.getItem('jentic_update_check')
if (cached) {
try {
setStatus(JSON.parse(cached))
return
} catch {
// ignore bad cache
}
}

async function check() {
try {
// Backend proxies the GitHub check with a 6h server-side cache —
// avoids browser hitting GitHub directly (rate limits, private repos)
const res = await fetch('/version')
if (!res.ok) return
const data = await res.json()
Expand All @@ -61,20 +52,42 @@ export function useUpdateCheck(): UpdateStatus {
if (!latestVersion) return

const updateAvailable = isNewer(latestVersion, currentVersion)
const upgradeAvailable = updateAvailable && !!data.upgrade_available
const result: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable,
releaseUrl,
upgradeAvailable,
}

try { sessionStorage.setItem('jentic_update_check', JSON.stringify(result)) } catch { /* private browsing */ }
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(result)) } catch { /* private browsing */ }
setStatus(result)
} catch {
// Silently ignore — network errors, etc.
}
}

// Use cache only if the currentVersion still matches the running server.
// After an upgrade the version changes, so the stale cache is discarded.
const cached = sessionStorage.getItem(CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached)
// Quick check: does the cached version match what /health reports?
fetch('/health').then(r => r.ok ? r.json() : null).then(health => {
if (health?.version && parsed.currentVersion !== health.version) {
sessionStorage.removeItem(CACHE_KEY)
check()
}
}).catch(() => {})
setStatus(parsed)
return
} catch {
// ignore bad cache
}
}

check()
}, [])

Expand Down
Loading