Dashboard Workflow
The dashboard is a server-rendered page at / (src/docsfy/templates/dashboard.html) with live updates from /api/status. It is built around project variants (name + ai_provider + ai_model + owner) and presents them grouped by repository name.
How project listing works
On page load, the backend resolves visible projects based on the authenticated user role, then groups variants by repository name.
From src/docsfy/main.py:
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
settings = get_settings()
username = request.state.username
is_admin = request.state.is_admin
if is_admin:
projects = await list_projects() # admin sees all
else:
accessible = await get_user_accessible_projects(username)
projects = await list_projects(owner=username, accessible=accessible)
known_models = await get_known_models()
# Group by repo name
grouped: dict[str, list[dict[str, Any]]] = {}
for p in projects:
name = str(p["name"])
if name not in grouped:
grouped[name] = []
grouped[name].append(p)
template = _jinja_env.get_template("dashboard.html")
html = template.render(
grouped_projects=grouped,
projects=projects, # keep for backward compat
default_provider=settings.ai_provider,
default_model=settings.ai_model,
known_models=known_models,
role=request.state.role,
username=request.state.username,
)
return HTMLResponse(content=html)
From src/docsfy/storage.py (project visibility and ordering):
async def list_projects(
owner: str | None = None,
accessible: list[tuple[str, str]] | None = None,
) -> list[dict[str, str | int | None]]:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
if owner is not None and accessible and len(accessible) > 0:
# Build OR conditions for each (name, owner) pair
conditions = ["(owner = ?)"]
params: list[str] = [owner]
for proj_name, proj_owner in accessible:
conditions.append("(name = ? AND owner = ?)")
params.extend([proj_name, proj_owner])
query = f"SELECT * FROM projects WHERE {' OR '.join(conditions)} ORDER BY updated_at DESC"
cursor = await db.execute(query, params)
elif owner is not None:
cursor = await db.execute(
"SELECT * FROM projects WHERE owner = ? ORDER BY updated_at DESC",
(owner,),
)
else:
cursor = await db.execute("SELECT * FROM projects ORDER BY updated_at DESC")
rows = await cursor.fetchall()
return [dict(row) for row in rows]
Variant cards and status-driven actions
Each project group contains one or more variant cards. Actions change based on variant status and role.
From src/docsfy/templates/dashboard.html:
{% for repo_name, variants in grouped_projects.items() %}
<article class="project-group" data-repo="{{ repo_name }}">
<div class="group-header">
<span class="group-name">{{ repo_name }}</span>
<span class="group-variant-count">{{ variants|length }} variant{{ 's' if variants|length > 1 else '' }}</span>
</div>
{% for variant in variants %}
<div class="variant-card"
data-project="{{ repo_name }}"
data-provider="{{ variant.ai_provider }}"
data-model="{{ variant.ai_model }}"
data-status="{{ variant.status }}">
{% if variant.status == 'ready' %}
<div class="variant-actions">
<a href="/docs/{{ repo_name }}/{{ variant.ai_provider | urlencode }}/{{ variant.ai_model | urlencode }}/" target="_blank" class="btn btn-primary btn-sm">View Docs</a>
<a href="/api/projects/{{ repo_name }}/{{ variant.ai_provider | urlencode }}/{{ variant.ai_model | urlencode }}/download" class="btn btn-secondary btn-sm">Download</a>
{% if role != 'viewer' %}
<button class="btn btn-danger btn-sm" data-delete-variant="{{ repo_name }}/{{ variant.ai_provider }}/{{ variant.ai_model }}">Delete</button>
{% endif %}
</div>
{% if role != 'viewer' %}
{{ regen_controls(variant, repo_name, default_provider, default_model, known_models) }}
{% endif %}
{% elif variant.status == 'generating' %}
<div class="variant-progress">
<div class="progress-bar-container">
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" data-field="progress-bar" style="width: 0%"></div>
</div>
</div>
<span class="progress-text">Generating...</span>
<a href="/status/{{ repo_name }}/{{ variant.ai_provider | urlencode }}/{{ variant.ai_model | urlencode }}" target="_blank" class="status-link">View progress →</a>
{% if role != 'viewer' %}
<button class="btn btn-danger btn-sm" data-abort-variant="{{ repo_name }}/{{ variant.ai_provider }}/{{ variant.ai_model }}">Abort</button>
{% endif %}
</div>
{% elif variant.status == 'error' or variant.status == 'aborted' %}
<div class="variant-error">
<span class="error-text">{{ variant.error_message }}</span>
</div>
<div class="variant-actions">
{% if role != 'viewer' %}
{{ regen_controls(variant, repo_name, default_provider, default_model, known_models) }}
<button class="btn btn-danger btn-sm" data-delete-variant="{{ repo_name }}/{{ variant.ai_provider }}/{{ variant.ai_model }}">Delete</button>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</article>
{% endfor %}
Filtering and pagination
Filtering and pagination are done in the browser over already-rendered project groups.
From src/docsfy/templates/dashboard.html:
var currentPage = 1;
var perPage = 10;
function getVisibleGroups() {
/* Get project groups that match the search filter (not hidden by search) */
return Array.from(document.querySelectorAll('.project-group')).filter(function(group) {
return !group.classList.contains('search-hidden');
});
}
function applyPagination() {
var groups = getVisibleGroups();
var totalPages = Math.max(1, Math.ceil(groups.length / perPage));
if (currentPage > totalPages) currentPage = totalPages;
var start = (currentPage - 1) * perPage;
var end = start + perPage;
groups.forEach(function(group, i) {
group.style.display = (i >= start && i < end) ? '' : 'none';
});
var pageInfo = document.getElementById('page-info');
var prevBtn = document.getElementById('prev-page');
var nextBtn = document.getElementById('next-page');
if (pageInfo) pageInfo.textContent = 'Page ' + currentPage + ' of ' + totalPages;
if (prevBtn) prevBtn.disabled = currentPage <= 1;
if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
}
var searchInput = document.getElementById('search-filter');
if (searchInput) {
searchInput.addEventListener('input', function() {
var query = this.value.toLowerCase().trim();
var groups = document.querySelectorAll('.project-group');
groups.forEach(function(group) {
var name = group.getAttribute('data-repo').toLowerCase();
if (!query || name.indexOf(query) !== -1) {
group.classList.remove('search-hidden');
} else {
group.classList.add('search-hidden');
group.style.display = 'none';
}
});
currentPage = 1;
applyPagination();
});
}
Note: Search matches only
data-repo(repository name), not provider/model text, and pagination applies to visible project groups after filtering.
Role-based UI and server enforcement
The dashboard has three roles: admin, user, and viewer.
admin: sees all projects, admin link, owner badges, and write controls.user: sees owned + granted projects and write controls (no admin panel link).viewer: read-only dashboard (no generate/regenerate/delete/abort controls).
From src/docsfy/templates/dashboard.html:
{% if role == 'admin' %}
<a href="/admin" class="top-bar-admin-link">Admin</a>
{% endif %}
{% if role != 'viewer' %}
<section class="generate-section">
<h2>Generate Documentation</h2>
...
</section>
{% endif %}
{% if role == 'admin' and variant.owner %}
<span class="variant-owner">{{ variant.owner }}</span>
{% endif %}
From src/docsfy/main.py:
def _require_write_access(request: Request) -> None:
"""Raise 403 if user is a viewer (read-only)."""
if request.state.role not in ("admin", "user"):
raise HTTPException(
status_code=403,
detail="Write access required.",
)
@app.post("/api/generate", status_code=202)
async def generate(request: Request, gen_request: GenerateRequest) -> dict[str, str]:
_require_write_access(request)
# Fix 9: Local repo path access requires admin privileges
if gen_request.repo_path and not request.state.is_admin:
raise HTTPException(
status_code=403,
detail="Local repo path access requires admin privileges",
)
From tests/test_auth.py:
async def test_viewer_can_view_dashboard(_init_db: None) -> None:
...
response = await ac.get("/")
assert response.status_code == 200
# Viewer should NOT see the generate form
assert "Generate Documentation" not in response.text
async def test_viewer_cannot_generate(_init_db: None) -> None:
...
response = await ac.post(
"/api/generate",
json={
"repo_url": "https://github.com/org/repo",
"project_name": "test-proj",
},
)
assert response.status_code == 403
assert "Write access required" in response.json()["detail"]
Warning: Write permissions are enforced server-side, not only hidden in the UI. Direct API calls from viewer accounts are rejected with
403.
Generation form behavior
The generate form is shown to non-viewers and includes:
Repository URL(required, URL input)Provider(claude,gemini,cursor)Model(free text + provider-filtered combobox suggestions)Forcecheckbox
From src/docsfy/templates/dashboard.html:
<form id="generate-form" autocomplete="off">
<div class="form-row">
<div class="form-group form-group-grow">
<label for="gen-repo-url">Repository URL</label>
<input type="url" id="gen-repo-url" class="form-input" placeholder="https://github.com/org/repo" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="gen-provider">Provider</label>
<select id="gen-provider" class="form-select">
<option value="claude"{% if default_provider == 'claude' %} selected{% endif %}>claude</option>
<option value="gemini"{% if default_provider == 'gemini' %} selected{% endif %}>gemini</option>
<option value="cursor"{% if default_provider == 'cursor' %} selected{% endif %}>cursor</option>
</select>
</div>
<div class="form-group form-group-grow">
<label for="gen-model">Model</label>
<div class="model-combobox">
<input type="text" class="form-input" id="gen-model" value="{{ default_model }}" placeholder="Model name" autocomplete="off">
</div>
</div>
<div class="form-checkbox-group">
<input type="checkbox" id="gen-force">
<label for="gen-force">Force</label>
</div>
<button type="submit" class="btn btn-primary" id="gen-submit">
Generate
</button>
</div>
</form>
Form state persistence
The form persists state in sessionStorage and restores it after reloads (useful because status changes may trigger auto-reloads).
function saveFormState() {
var repoInput = document.getElementById('gen-repo-url');
var providerSelect = document.getElementById('gen-provider');
var modelInput = document.getElementById('gen-model');
var forceCheck = document.getElementById('gen-force');
if (repoInput) sessionStorage.setItem('docsfy-repo', repoInput.value);
if (providerSelect) sessionStorage.setItem('docsfy-provider', providerSelect.value);
if (modelInput) sessionStorage.setItem('docsfy-model', modelInput.value);
if (forceCheck) sessionStorage.setItem('docsfy-force', forceCheck.checked ? '1' : '0');
}
Submit behavior
On submit, the UI disables the button, sends POST /api/generate, shows a toast with a status link, and reloads.
form.addEventListener('submit', function(e) {
e.preventDefault();
var repoUrl = document.getElementById('gen-repo-url').value.trim();
var provider = document.getElementById('gen-provider').value;
var model = document.getElementById('gen-model').value.trim();
var force = document.getElementById('gen-force').checked;
var body = {
repo_url: repoUrl,
ai_provider: provider,
force: force
};
if (model) body.ai_model = model;
fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
redirect: 'manual',
body: JSON.stringify(body)
})
Backend request validation
From src/docsfy/models.py:
@model_validator(mode="after")
def validate_source(self) -> GenerateRequest:
if not self.repo_url and not self.repo_path:
msg = "Either 'repo_url' or 'repo_path' must be provided"
raise ValueError(msg)
if self.repo_url and self.repo_path:
msg = "Provide either 'repo_url' or 'repo_path', not both"
raise ValueError(msg)
return self
@property
def project_name(self) -> str:
if self.repo_url:
name = self.repo_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
return name
if self.repo_path:
return Path(self.repo_path).resolve().name
return "unknown"
Generation lifecycle, duplicate protection, and force
API-side orchestration
From src/docsfy/main.py:
settings = get_settings()
ai_provider = gen_request.ai_provider or settings.ai_provider
ai_model = gen_request.ai_model or settings.ai_model
project_name = gen_request.project_name
owner = request.state.username
if ai_provider not in ("claude", "gemini", "cursor"):
raise HTTPException(
status_code=400,
detail=f"Invalid AI provider: '{ai_provider}'. Must be claude, gemini, or cursor.",
)
if not ai_model:
raise HTTPException(status_code=400, detail="AI model must be specified.")
# Fix 6: Use lock to prevent race condition between check and add
gen_key = f"{owner}/{project_name}/{ai_provider}/{ai_model}"
async with _gen_lock:
if gen_key in _generating:
raise HTTPException(
status_code=409,
detail=f"Variant '{project_name}/{ai_provider}/{ai_model}' is already being generated",
)
await save_project(
name=project_name,
repo_url=gen_request.repo_url or gen_request.repo_path or "",
status="generating",
ai_provider=ai_provider,
ai_model=ai_model,
owner=owner,
)
task = asyncio.create_task(
_run_generation(
repo_url=gen_request.repo_url,
repo_path=gen_request.repo_path,
project_name=project_name,
ai_provider=ai_provider,
ai_model=ai_model,
ai_cli_timeout=gen_request.ai_cli_timeout or settings.ai_cli_timeout,
force=gen_request.force,
owner=owner,
)
)
_generating[gen_key] = task
return {"project": project_name, "status": "generating"}
force and incremental behavior
From src/docsfy/main.py (_generate_from_path):
if force:
cache_dir = get_project_cache_dir(project_name, ai_provider, ai_model, owner)
if cache_dir.exists():
shutil.rmtree(cache_dir)
logger.info(f"[{project_name}] Cleared cache (force=True)")
# Reset page count so API shows 0 during regeneration
await update_project_status(
project_name,
ai_provider,
ai_model,
status="generating",
owner=owner,
page_count=0,
)
else:
existing = await get_project(
project_name, ai_provider=ai_provider, ai_model=ai_model, owner=owner
)
if existing and existing.get("last_generated"):
old_sha = (
str(existing["last_commit_sha"])
if existing.get("last_commit_sha")
else None
)
if old_sha == commit_sha:
logger.info(
f"[{project_name}] Project is up to date at {commit_sha[:8]}"
)
await update_project_status(
project_name,
ai_provider,
ai_model,
status="ready",
owner=owner,
current_stage="up_to_date",
)
return
if old_sha and old_sha != commit_sha and not force and existing:
changed_files = get_changed_files(repo_dir, old_sha, commit_sha)
...
pages_to_regen = await run_incremental_planner(
repo_dir,
project_name,
ai_provider,
ai_model,
changed_files,
existing_plan,
ai_cli_timeout,
)
if pages_to_regen != ["all"]:
# Delete only the cached pages that need regeneration
for slug in pages_to_regen:
...
cache_file = cache_dir / f"{slug}.md"
...
if cache_file.exists():
cache_file.unlink()
use_cache = True
Tip: Keep
Forceunchecked for normal runs to allow up-to-date short-circuiting and incremental regeneration from cache; useForcewhen you need a full clean rebuild.
Polling behavior and live refresh
The dashboard uses two polling loops:
10sstatus polling for variant state changes/new cards.5sprogress polling while any variant is generating.
From src/docsfy/templates/dashboard.html:
var statusPollInterval = null; // Slow poll for status changes (10s)
var progressPollInterval = null; // Fast poll for progress updates (5s)
function startStatusPolling() {
if (isStatusPolling) return;
isStatusPolling = true;
statusPollInterval = setInterval(pollStatusChanges, 10000);
}
function startProgressPolling() {
if (isProgressPolling) return;
isProgressPolling = true;
progressPollInterval = setInterval(pollProgressUpdates, 5000);
}
The same /api/status response also refreshes known model suggestions dynamically:
if (data.known_models) {
knownModels = data.known_models;
rebuildModelDropdownOptions();
}
Configuration relevant to dashboard workflow
Default generation settings
From .env.example:
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
AI_PROVIDER=claude
AI_MODEL=claude-opus-4-6[1m]
AI_CLI_TIMEOUT=60
# SECURE_COOKIES=false
From src/docsfy/config.py:
class Settings(BaseSettings):
...
admin_key: str = "" # Required — validated at startup
ai_provider: str = "claude"
ai_model: str = "claude-opus-4-6[1m]" # [1m] = 1 million token context window
ai_cli_timeout: int = Field(default=60, gt=0)
...
secure_cookies: bool = True # Set to False for local HTTP dev
Persistence/deployment
From docker-compose.yaml:
services:
docsfy:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
./data persists database state and generated project artifacts that drive dashboard listings/status across restarts.
Verification references
tests/test_dashboard.py: dashboard rendering, empty state, and project visibility.tests/test_auth.py: role behavior (admin/user/viewer), ownership scoping, access grants, and server-side permission checks.tests/test_main.py:/api/generate, duplicate generation conflicts (409), and endpoint behavior.test-plans/e2e-ui-test-plan.md: manual/E2E scenarios for search, pagination, regenerate, abort, and role-specific UI.
Note: No
.github/workflowspipeline files are present in this repository; dashboard workflow correctness is primarily represented by the test suite and E2E plan.