web: dashboard + settings editor (HTMX save) + pipeline observability
Adds three new routes on the FastAPI web app:
- GET /dashboard — pipeline counts by status (per-status drill-down
via filtered /?status=… links), recent activity
(latest 10 sources), and a settings snapshot
card with an "edit →" shortcut.
- GET /settings — full editing form for pipeline_settings.
- POST /settings/save — HTMX endpoint that validates the partial form,
upserts the row, and returns the rerendered
card with a "Saved." or error flash. The card
is its own _settings_card.html partial so the
HTMX swap targets only the form region.
Form parsing lives in settings_store helpers (parse_time_or_none,
parse_int_or_none) — keeps the route thin and the empty-string-to-NULL
normalisation in one place. Validation rejects negative counts and
sub-1 GPU concurrency.
The base template grew a Dashboard + Settings nav so the new routes are
reachable without typing URLs. Added python-multipart to deps because
FastAPI's Form(...) raises at app import without it.
This commit is contained in:
parent
09efbb5965
commit
c92a40bce1
@ -15,6 +15,7 @@ dependencies = [
|
||||
"psycopg-pool>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"python-multipart>=0.0.20",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"tomli>=2.0.0",
|
||||
"trafilatura>=1.9.0",
|
||||
|
||||
@ -14,14 +14,21 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
|
||||
from second_brain.config import DOMAINS
|
||||
from second_brain.database import get_database
|
||||
from second_brain.models import Extraction, Source, SourceStatus
|
||||
from second_brain.settings_store import (
|
||||
get_settings,
|
||||
parse_int_or_none,
|
||||
parse_time_or_none,
|
||||
update_settings,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup
|
||||
@ -295,3 +302,176 @@ def _source_to_response(s: Source) -> SourceResponse:
|
||||
ingested_at=s.ingested_at.isoformat() if s.ingested_at else "",
|
||||
has_extraction=s.extraction is not None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard — pipeline observability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Overview: status counts, recent activity, and a settings snapshot."""
|
||||
db = get_database()
|
||||
with db.session() as sess:
|
||||
# Counts by status. Materialise inside the session so the keys stay
|
||||
# plain str → int and templates don't try to touch a closed session.
|
||||
rows = (
|
||||
sess.query(Source.status, func.count(Source.id))
|
||||
.group_by(Source.status)
|
||||
.all()
|
||||
)
|
||||
counts = {s.value: 0 for s in SourceStatus}
|
||||
for status, n in rows:
|
||||
counts[status.value] = int(n)
|
||||
total = sum(counts.values())
|
||||
|
||||
recent_rows = (
|
||||
sess.query(Source)
|
||||
.order_by(Source.ingested_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
recent = [_source_to_dict(s) for s in recent_rows]
|
||||
|
||||
settings_row = get_settings(sess)
|
||||
settings_summary = _settings_to_dict(settings_row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"counts": counts,
|
||||
"total": total,
|
||||
"recent": recent,
|
||||
"statuses_in_order": [s.value for s in SourceStatus],
|
||||
"settings": settings_summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings — view + HTMX save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_view(request: Request):
|
||||
db = get_database()
|
||||
with db.session() as sess:
|
||||
row = get_settings(sess)
|
||||
ctx = _settings_to_dict(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"settings.html",
|
||||
{"settings": ctx, "flash": None, "error": None},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/settings/save", response_class=HTMLResponse)
|
||||
async def settings_save(
|
||||
request: Request,
|
||||
transcription_enabled: Optional[str] = Form(None),
|
||||
transcription_active_hours_start: Optional[str] = Form(None),
|
||||
transcription_active_hours_end: Optional[str] = Form(None),
|
||||
transcription_max_concurrent_gpu_jobs: Optional[str] = Form(None),
|
||||
transcription_max_video_length_seconds: Optional[str] = Form(None),
|
||||
transcription_max_items_per_run: Optional[str] = Form(None),
|
||||
extraction_enabled: Optional[str] = Form(None),
|
||||
extraction_active_hours_start: Optional[str] = Form(None),
|
||||
extraction_active_hours_end: Optional[str] = Form(None),
|
||||
extraction_max_items_per_run: Optional[str] = Form(None),
|
||||
):
|
||||
"""HTMX save endpoint — returns the rendered settings card with a flash."""
|
||||
db = get_database()
|
||||
error: Optional[str] = None
|
||||
flash: Optional[str] = None
|
||||
|
||||
try:
|
||||
fields = {
|
||||
# Checkboxes only show up in the form payload when checked. The
|
||||
# template emits a hidden "_present" companion so we can tell
|
||||
# "unchecked" apart from "field missing because of a partial post".
|
||||
"transcription_enabled": transcription_enabled == "on",
|
||||
"transcription_active_hours_start": parse_time_or_none(
|
||||
transcription_active_hours_start
|
||||
),
|
||||
"transcription_active_hours_end": parse_time_or_none(
|
||||
transcription_active_hours_end
|
||||
),
|
||||
"transcription_max_concurrent_gpu_jobs": int(
|
||||
transcription_max_concurrent_gpu_jobs or "1"
|
||||
),
|
||||
"transcription_max_video_length_seconds": parse_int_or_none(
|
||||
transcription_max_video_length_seconds
|
||||
),
|
||||
"transcription_max_items_per_run": int(
|
||||
transcription_max_items_per_run or "10"
|
||||
),
|
||||
"extraction_enabled": extraction_enabled == "on",
|
||||
"extraction_active_hours_start": parse_time_or_none(
|
||||
extraction_active_hours_start
|
||||
),
|
||||
"extraction_active_hours_end": parse_time_or_none(
|
||||
extraction_active_hours_end
|
||||
),
|
||||
"extraction_max_items_per_run": int(extraction_max_items_per_run or "20"),
|
||||
}
|
||||
# Cheap inline validation — keep counts non-negative and gpu jobs >= 1.
|
||||
if fields["transcription_max_concurrent_gpu_jobs"] < 1:
|
||||
raise ValueError("transcription_max_concurrent_gpu_jobs must be ≥ 1")
|
||||
if fields["transcription_max_items_per_run"] < 0:
|
||||
raise ValueError("transcription_max_items_per_run must be ≥ 0")
|
||||
if fields["extraction_max_items_per_run"] < 0:
|
||||
raise ValueError("extraction_max_items_per_run must be ≥ 0")
|
||||
if (
|
||||
fields["transcription_max_video_length_seconds"] is not None
|
||||
and fields["transcription_max_video_length_seconds"] < 0
|
||||
):
|
||||
raise ValueError("transcription_max_video_length_seconds must be ≥ 0")
|
||||
|
||||
with db.session() as sess:
|
||||
row = update_settings(sess, **fields)
|
||||
ctx = _settings_to_dict(row)
|
||||
flash = "Saved."
|
||||
except (ValueError, KeyError) as exc:
|
||||
error = str(exc)
|
||||
# Re-render the form using whatever the user submitted so they don't
|
||||
# lose in-progress edits on a validation bounce.
|
||||
with db.session() as sess:
|
||||
ctx = _settings_to_dict(get_settings(sess))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"_settings_card.html",
|
||||
{"settings": ctx, "flash": flash, "error": error},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings serialiser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings_to_dict(row) -> dict:
|
||||
"""Render the settings row as a template-friendly dict (no ORM bleed)."""
|
||||
|
||||
def t(v):
|
||||
return v.strftime("%H:%M") if v is not None else ""
|
||||
|
||||
return {
|
||||
"transcription_enabled": row.transcription_enabled,
|
||||
"transcription_active_hours_start": t(row.transcription_active_hours_start),
|
||||
"transcription_active_hours_end": t(row.transcription_active_hours_end),
|
||||
"transcription_max_concurrent_gpu_jobs": row.transcription_max_concurrent_gpu_jobs,
|
||||
"transcription_max_video_length_seconds": row.transcription_max_video_length_seconds,
|
||||
"transcription_max_items_per_run": row.transcription_max_items_per_run,
|
||||
"extraction_enabled": row.extraction_enabled,
|
||||
"extraction_active_hours_start": t(row.extraction_active_hours_start),
|
||||
"extraction_active_hours_end": t(row.extraction_active_hours_end),
|
||||
"extraction_max_items_per_run": row.extraction_max_items_per_run,
|
||||
"updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if row.updated_at
|
||||
else "",
|
||||
}
|
||||
|
||||
130
src/second_brain/web/templates/_settings_card.html
Normal file
130
src/second_brain/web/templates/_settings_card.html
Normal file
@ -0,0 +1,130 @@
|
||||
{# HTMX-replaceable inner card. Posted to /settings/save; swaps itself in place. #}
|
||||
<div id="settings-card" class="bg-white rounded-lg shadow-sm p-6">
|
||||
{% if flash %}
|
||||
<div class="mb-4 px-3 py-2 rounded bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||
✓ {{ flash }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="mb-4 px-3 py-2 rounded bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
✗ {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/settings/save"
|
||||
hx-target="#settings-card"
|
||||
hx-swap="outerHTML"
|
||||
class="space-y-8">
|
||||
|
||||
<!-- Transcription -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-800">Transcription</h3>
|
||||
<p class="text-xs text-gray-400">consumed by tower-side worker (not yet built)</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<!-- enabled -->
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" name="transcription_enabled"
|
||||
{% if settings.transcription_enabled %}checked{% endif %}
|
||||
class="peer sr-only">
|
||||
<span class="w-10 h-5 bg-gray-300 rounded-full peer-checked:bg-indigo-600 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
</label>
|
||||
|
||||
<div class="md:col-span-1"></div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — start</label>
|
||||
<input type="time" name="transcription_active_hours_start"
|
||||
value="{{ settings.transcription_active_hours_start }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
<p class="text-xs text-gray-400 mt-1">blank = always active</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — end</label>
|
||||
<input type="time" name="transcription_active_hours_end"
|
||||
value="{{ settings.transcription_active_hours_end }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max concurrent GPU jobs</label>
|
||||
<input type="number" min="1" name="transcription_max_concurrent_gpu_jobs"
|
||||
value="{{ settings.transcription_max_concurrent_gpu_jobs }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max items per run</label>
|
||||
<input type="number" min="0" name="transcription_max_items_per_run"
|
||||
value="{{ settings.transcription_max_items_per_run }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max video length (seconds)</label>
|
||||
<input type="number" min="0" name="transcription_max_video_length_seconds"
|
||||
value="{{ settings.transcription_max_video_length_seconds if settings.transcription_max_video_length_seconds is not none else '' }}"
|
||||
placeholder="blank = no cap"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full max-w-xs">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Extraction -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-800">Extraction</h3>
|
||||
<p class="text-xs text-gray-400">consumed by the dev-side scheduler</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" name="extraction_enabled"
|
||||
{% if settings.extraction_enabled %}checked{% endif %}
|
||||
class="peer sr-only">
|
||||
<span class="w-10 h-5 bg-gray-300 rounded-full peer-checked:bg-indigo-600 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5"></span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
</label>
|
||||
|
||||
<div class="md:col-span-1"></div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — start</label>
|
||||
<input type="time" name="extraction_active_hours_start"
|
||||
value="{{ settings.extraction_active_hours_start }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
<p class="text-xs text-gray-400 mt-1">blank = always active</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Active hours — end</label>
|
||||
<input type="time" name="extraction_active_hours_end"
|
||||
value="{{ settings.extraction_active_hours_end }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Max items per run</label>
|
||||
<input type="number" min="0" name="extraction_max_items_per_run"
|
||||
value="{{ settings.extraction_max_items_per_run }}"
|
||||
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400">last saved {{ settings.updated_at }}</p>
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -16,8 +16,10 @@
|
||||
<p class="text-indigo-200 text-sm mt-0.5">personal knowledge pipeline</p>
|
||||
</div>
|
||||
<nav class="flex gap-4 text-sm font-medium">
|
||||
<a href="/dashboard" class="hover:text-indigo-200 transition-colors">Dashboard</a>
|
||||
<a href="/" class="hover:text-indigo-200 transition-colors">Sources</a>
|
||||
<a href="/queue" class="hover:text-indigo-200 transition-colors">Queue</a>
|
||||
<a href="/settings" class="hover:text-indigo-200 transition-colors">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
158
src/second_brain/web/templates/dashboard.html
Normal file
158
src/second_brain/web/templates/dashboard.html
Normal file
@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard — second-brain{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set status_colors = {
|
||||
'pending': 'bg-gray-100 text-gray-700',
|
||||
'pulled': 'bg-blue-100 text-blue-700',
|
||||
'transcribed': 'bg-yellow-100 text-yellow-700',
|
||||
'analyzed': 'bg-orange-100 text-orange-700',
|
||||
'accepted': 'bg-green-100 text-green-700',
|
||||
'published': 'bg-purple-100 text-purple-700',
|
||||
'failed': 'bg-red-100 text-red-700'
|
||||
} %}
|
||||
{% set domain_colors = {
|
||||
'development': 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
'content': 'bg-pink-50 text-pink-700 border-pink-200',
|
||||
'business': 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'homelab': 'bg-teal-50 text-teal-700 border-teal-200'
|
||||
} %}
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold text-gray-800">Dashboard</h2>
|
||||
<span class="text-sm text-gray-500">total sources: {{ total }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline overview -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
|
||||
Pipeline
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
{% for status in statuses_in_order %}
|
||||
<a href="/?status={{ status }}"
|
||||
class="block rounded-lg border border-gray-200 hover:border-indigo-400 hover:shadow-sm transition-all px-3 py-3">
|
||||
<div class="text-2xl font-bold text-gray-900 leading-none">{{ counts.get(status, 0) }}</div>
|
||||
<div class="mt-1.5">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium
|
||||
{{ status_colors.get(status, 'bg-gray-100 text-gray-700') }}">
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings snapshot + Recent activity, side by side on wide screens -->
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Settings snapshot -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-1">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Settings
|
||||
</h3>
|
||||
<a href="/settings" class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
edit →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<dl class="text-sm space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600">Transcription</dt>
|
||||
<dd>
|
||||
{% if settings.transcription_enabled %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> active window</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">
|
||||
{% if settings.transcription_active_hours_start or settings.transcription_active_hours_end %}
|
||||
{{ settings.transcription_active_hours_start or "—" }} – {{ settings.transcription_active_hours_end or "—" }}
|
||||
{% else %}
|
||||
always
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> GPU jobs</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_concurrent_gpu_jobs }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> max items/run</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_items_per_run }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-2 mt-2"></div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600">Extraction</dt>
|
||||
<dd>
|
||||
{% if settings.extraction_enabled %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
|
||||
{% else %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> active window</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">
|
||||
{% if settings.extraction_active_hours_start or settings.extraction_active_hours_end %}
|
||||
{{ settings.extraction_active_hours_start or "—" }} – {{ settings.extraction_active_hours_end or "—" }}
|
||||
{% else %}
|
||||
always
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<dt class="text-gray-600"> max items/run</dt>
|
||||
<dd class="text-gray-800 font-mono text-xs">{{ settings.extraction_max_items_per_run }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="text-xs text-gray-400 mt-4">
|
||||
updated {{ settings.updated_at }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-2">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
|
||||
Recent activity
|
||||
</h3>
|
||||
{% if recent %}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{% for source in recent %}
|
||||
<a href="/sources/{{ source.id }}"
|
||||
class="flex items-start justify-between gap-3 py-2.5 hover:bg-gray-50 -mx-2 px-2 rounded transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ source.title }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ source.url }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium
|
||||
{{ status_colors.get(source.status, 'bg-gray-100 text-gray-700') }}">
|
||||
{{ source.status }}
|
||||
</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded border font-medium
|
||||
{{ domain_colors.get(source.domain, 'bg-gray-50 text-gray-600 border-gray-200') }}">
|
||||
{{ source.domain }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ source.ingested_at }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 py-8 text-center">No sources yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
14
src/second_brain/web/templates/settings.html
Normal file
14
src/second_brain/web/templates/settings.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings — second-brain{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-800">Settings</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Workers read these at the start of each run. DB values override <code class="bg-gray-100 px-1 rounded text-xs">settings.toml</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% include "_settings_card.html" %}
|
||||
{% endblock %}
|
||||
11
uv.lock
generated
11
uv.lock
generated
@ -1385,6 +1385,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@ -1584,6 +1593,7 @@ dependencies = [
|
||||
{ name = "psycopg-pool" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "tomli" },
|
||||
{ name = "trafilatura" },
|
||||
@ -1617,6 +1627,7 @@ requires-dist = [
|
||||
{ name = "psycopg-pool", specifier = ">=3.2" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "tomli", specifier = ">=2.0.0" },
|
||||
{ name = "trafilatura", specifier = ">=1.9.0" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user