feat(01-01): create Docker scaffold, FastAPI app shell, and sidebar templates
- Dockerfile with python:3.12-slim-bookworm, curl downloads Pico CSS, HTMX, Alpine.js at build time
- docker-compose.yml with imptune_data:/data named volume and restart policy
- requirements.txt with all phase 1-5 dependencies
- imptune/config.py loading DATA_DIR/PORT from env with DB_PATH and DRIVERS_DIR derived paths
- imptune/main.py: FastAPI app with StaticFiles mount, health+pages routers, startup dir creation
- imptune/api/health.py: GET /health returning {"status": "ok"}
- imptune/api/pages.py: GET / returning dashboard.html with sync def handler
- imptune/templates/base.html: data-theme="auto", sidebar with 5 nav sections, /static/ paths only
- imptune/templates/dashboard.html: quick actions + empty state recent activity
- imptune/static/app.css: sidebar layout, active link highlight, quick action buttons
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps, download frontend assets, purge curl in one layer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& mkdir -p /app/imptune/static \
|
||||
&& curl -sL --fail -o /app/imptune/static/pico.min.css \
|
||||
"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" \
|
||||
&& curl -sL --fail -o /app/imptune/static/htmx.min.js \
|
||||
"https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
|
||||
&& curl -sL --fail -o /app/imptune/static/alpine.min.js \
|
||||
"https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" \
|
||||
&& apt-get purge -y curl && apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY imptune/ /app/imptune/
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "imptune.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
imptune:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- imptune_data:/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
|
||||
volumes:
|
||||
imptune_data:
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"recent_printers": [],
|
||||
"recent_packages": [],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
|
||||
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
||||
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from imptune.api import health, pages
|
||||
from imptune.config import DATA_DIR, DRIVERS_DIR
|
||||
|
||||
app = FastAPI(title="ImpTune")
|
||||
|
||||
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
|
||||
_static_dir = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
||||
|
||||
# Register routers
|
||||
app.include_router(health.router)
|
||||
app.include_router(pages.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
import os
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(DRIVERS_DIR, exist_ok=True)
|
||||
@@ -0,0 +1,93 @@
|
||||
/* ImpTune layout — supplements Pico CSS */
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--pico-muted-border-color, #e0e0e0);
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: block;
|
||||
padding: 0.6rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
background-color: var(--pico-secondary-background, rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
.sidebar-nav a.active {
|
||||
font-weight: 600;
|
||||
background-color: var(--pico-primary-background, rgba(0,0,0,0.08));
|
||||
border-left: 3px solid var(--pico-primary, #1a73e8);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Quick action buttons */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--pico-primary, #1a73e8);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--pico-primary, #1a73e8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-action[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
color: var(--pico-muted-color, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recent activity */
|
||||
.activity-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ImpTune</title>
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script defer src="/static/alpine.min.js"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<strong>ImpTune</strong>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>Dashboard</a></li>
|
||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>Drivers</a></li>
|
||||
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}>Printers</a></li>
|
||||
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}>Clients</a></li>
|
||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>Packages</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="main-content container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="quick-actions">
|
||||
<a href="#" aria-disabled="true" class="btn-action">New Printer</a>
|
||||
<a href="#" aria-disabled="true" class="btn-action">Upload Driver</a>
|
||||
<a href="#" aria-disabled="true" class="btn-action">Export Package</a>
|
||||
</div>
|
||||
|
||||
<section class="recent-activity">
|
||||
<h2>Recent Activity</h2>
|
||||
|
||||
<div class="activity-section">
|
||||
<h3>Recent Printers</h3>
|
||||
{% if recent_printers %}
|
||||
<ul>
|
||||
{% for printer in recent_printers %}
|
||||
<li>{{ printer.name }} — {{ printer.ip_address }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No printers configured yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="activity-section">
|
||||
<h3>Recent Packages</h3>
|
||||
{% if recent_packages %}
|
||||
<ul>
|
||||
{% for package in recent_packages %}
|
||||
<li>{{ package }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No packages exported yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.*
|
||||
uvicorn[standard]==0.30.*
|
||||
jinja2==3.1.*
|
||||
python-multipart==0.0.9
|
||||
pycryptodome==3.20.*
|
||||
python-dotenv==1.0.*
|
||||
peewee==3.17.*
|
||||
Reference in New Issue
Block a user