web: add-to-queue form on the dashboard (HTMX, parity with CLI add)
Extracts the `second-brain add` CLI's queueing logic into a service
module (sources_service.add_source) so the CLI and the new web POST
route share the same validation, dedupe, and source_type heuristic —
no behaviour drift between the two entry points.
UI:
- The dashboard body (Pipeline counts + Settings snapshot + Recent
activity) moves into _dashboard_body.html, wrapped in
`<div id="dashboard-body">`. HTMX targets that id for swap.
- A new "Add to queue" section sits at the top of the partial: URL
(required, type=url), Domain (select matching DOMAINS), Title and
Focus (both optional, match the CLI flags). hx-post=/sources/add,
hx-target=#dashboard-body, hx-swap=outerHTML — same pattern as the
settings save form.
Route:
- POST /sources/add calls sources_service.add_source inside a single
transaction, then re-renders _dashboard_body.html so the pipeline
counts and recent-activity list update in place. Flash slots above
the form report:
✓ Queued <type>: <url> on a fresh add
✓ Already queued (id=…, status=…) on a dupe (matches CLI text)
✗ <validation message> on bad URL / unknown domain
CLI:
- `second-brain add` now delegates to the same service. Field values
for the success echo are captured inside the session block so a
post-commit detached-instance access can't fail. Bad input exits 2
with a clear stderr message instead of raising.
Live-verified through the web container on herbys-dev: dashboard
renders the form, happy add lands a row, dup-detect matches the CLI
phrasing, two validation errors surface as red flashes, swap target
id survives across swaps, no tracebacks in uvicorn logs.
This commit is contained in:
parent
597c83649c
commit
956bf8d0c3
@ -11,8 +11,8 @@ Commands:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@ -50,32 +50,33 @@ def cli() -> None:
|
|||||||
@click.option("--title", "-t", default=None, help="Override title.")
|
@click.option("--title", "-t", default=None, help="Override title.")
|
||||||
def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None:
|
def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None:
|
||||||
"""Queue a source URL for processing."""
|
"""Queue a source URL for processing."""
|
||||||
|
from second_brain.sources_service import add_source
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config.ensure_dirs()
|
config.ensure_dirs()
|
||||||
db = get_database()
|
db = get_database()
|
||||||
|
|
||||||
source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO
|
try:
|
||||||
|
|
||||||
with db.session() as sess:
|
with db.session() as sess:
|
||||||
existing = sess.query(Source).filter_by(url=url).first()
|
result = add_source(
|
||||||
if existing:
|
sess, url=url, domain=domain, focus=focus, title=title
|
||||||
click.echo(
|
|
||||||
f"[add] Already queued (id={existing.id}, status={existing.status.value})"
|
|
||||||
)
|
)
|
||||||
|
# Capture display fields inside the session — `result.source`
|
||||||
|
# detaches on commit and accessing it later would error.
|
||||||
|
source_id = result.source.id
|
||||||
|
source_url = result.source.url
|
||||||
|
source_type_value = result.source.source_type.value
|
||||||
|
existing_status = result.source.status.value
|
||||||
|
added = result.added
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"[add] Error: {exc}", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if not added:
|
||||||
|
click.echo(f"[add] Already queued (id={source_id}, status={existing_status})")
|
||||||
return
|
return
|
||||||
|
|
||||||
source = Source(
|
click.echo(f"[add] Queued {source_type_value}: {source_url}")
|
||||||
url=url,
|
|
||||||
title=title,
|
|
||||||
domain=domain,
|
|
||||||
focus=focus,
|
|
||||||
source_type=source_type,
|
|
||||||
status=SourceStatus.PENDING,
|
|
||||||
ingested_at=utcnow(),
|
|
||||||
)
|
|
||||||
sess.add(source)
|
|
||||||
|
|
||||||
click.echo(f"[add] Queued {source_type.value}: {url}")
|
|
||||||
click.echo(f" domain={domain}" + (f" focus={focus}" if focus else ""))
|
click.echo(f" domain={domain}" + (f" focus={focus}" if focus else ""))
|
||||||
|
|
||||||
|
|
||||||
@ -348,19 +349,5 @@ def transcribe_worker(
|
|||||||
click.echo(f"[transcribe-worker] exiting; processed={processed}")
|
click.echo(f"[transcribe-worker] exiting; processed={processed}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _is_article(url: str) -> bool:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
video_hosts = {
|
|
||||||
"youtube.com", "www.youtube.com", "youtu.be",
|
|
||||||
"vimeo.com", "www.vimeo.com",
|
|
||||||
}
|
|
||||||
return parsed.netloc not in video_hosts
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|||||||
112
src/second_brain/sources_service.py
Normal file
112
src/second_brain/sources_service.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""Service-layer helpers for the `sources` table.
|
||||||
|
|
||||||
|
Single source of truth for "queue a URL" — both the `second-brain add`
|
||||||
|
CLI and the web POST /sources/add route call into here, so they can't
|
||||||
|
drift on validation, dedupe, or default semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from second_brain.config import DOMAINS
|
||||||
|
from second_brain.models import Source, SourceStatus, SourceType, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
_VIDEO_HOSTS = {
|
||||||
|
"youtube.com",
|
||||||
|
"www.youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"vimeo.com",
|
||||||
|
"www.vimeo.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_article_url(url: str) -> bool:
|
||||||
|
"""Match the CLI's `_is_article` heuristic: anything not on a known
|
||||||
|
video host is treated as an article."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return parsed.netloc not in _VIDEO_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url: str) -> str:
|
||||||
|
"""Normalize and validate the input URL. Raises ValueError on bad shape."""
|
||||||
|
if url is None:
|
||||||
|
raise ValueError("url is required")
|
||||||
|
url = url.strip()
|
||||||
|
if not url:
|
||||||
|
raise ValueError("url is required")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError("url must use http or https")
|
||||||
|
if not parsed.netloc:
|
||||||
|
raise ValueError("url is missing a host")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddResult:
|
||||||
|
"""Outcome of `add_source`. `added=False` means the URL was already queued."""
|
||||||
|
|
||||||
|
source: Source
|
||||||
|
added: bool
|
||||||
|
|
||||||
|
|
||||||
|
def add_source(
|
||||||
|
sess: Session,
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
domain: str = "development",
|
||||||
|
focus: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
) -> AddResult:
|
||||||
|
"""Queue a single source URL.
|
||||||
|
|
||||||
|
Mirrors the CLI add path exactly:
|
||||||
|
- normalize + validate URL
|
||||||
|
- reject unknown domains
|
||||||
|
- if the URL is already queued, return the existing row with added=False
|
||||||
|
- otherwise insert a PENDING row and return it with added=True
|
||||||
|
|
||||||
|
Does NOT open or commit a session — caller owns the transaction
|
||||||
|
boundary (matches the rest of the codebase). For the CLI, that's
|
||||||
|
`db.session()`; for the web route, the request handler's
|
||||||
|
`with db.session()` block.
|
||||||
|
"""
|
||||||
|
url = validate_url(url)
|
||||||
|
|
||||||
|
if domain not in DOMAINS:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown domain {domain!r}; valid: {', '.join(DOMAINS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
title = (title or "").strip() or None
|
||||||
|
focus = (focus or "").strip() or None
|
||||||
|
|
||||||
|
existing = sess.query(Source).filter_by(url=url).first()
|
||||||
|
if existing is not None:
|
||||||
|
return AddResult(source=existing, added=False)
|
||||||
|
|
||||||
|
source_type = (
|
||||||
|
SourceType.ARTICLE if is_article_url(url) else SourceType.VIDEO
|
||||||
|
)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
url=url,
|
||||||
|
title=title,
|
||||||
|
domain=domain,
|
||||||
|
focus=focus,
|
||||||
|
source_type=source_type,
|
||||||
|
status=SourceStatus.PENDING,
|
||||||
|
ingested_at=utcnow(),
|
||||||
|
)
|
||||||
|
sess.add(source)
|
||||||
|
sess.flush()
|
||||||
|
return AddResult(source=source, added=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AddResult", "add_source", "is_article_url", "validate_url"]
|
||||||
@ -29,6 +29,7 @@ from second_brain.settings_store import (
|
|||||||
parse_time_or_none,
|
parse_time_or_none,
|
||||||
update_settings,
|
update_settings,
|
||||||
)
|
)
|
||||||
|
from second_brain.sources_service import add_source
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App setup
|
# App setup
|
||||||
@ -309,13 +310,17 @@ def _source_to_response(s: Source) -> SourceResponse:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/dashboard", response_class=HTMLResponse)
|
def _build_dashboard_context(
|
||||||
async def dashboard(request: Request):
|
sess,
|
||||||
"""Overview: status counts, recent activity, and a settings snapshot."""
|
*,
|
||||||
db = get_database()
|
add_flash: Optional[str] = None,
|
||||||
with db.session() as sess:
|
add_error: Optional[str] = None,
|
||||||
# Counts by status. Materialise inside the session so the keys stay
|
) -> dict:
|
||||||
# plain str → int and templates don't try to touch a closed session.
|
"""Collect everything the dashboard partial needs from one session.
|
||||||
|
|
||||||
|
Materialise inside the session so the keys stay plain str → int /
|
||||||
|
detached dicts; templates never touch a closed session.
|
||||||
|
"""
|
||||||
rows = (
|
rows = (
|
||||||
sess.query(Source.status, func.count(Source.id))
|
sess.query(Source.status, func.count(Source.id))
|
||||||
.group_by(Source.status)
|
.group_by(Source.status)
|
||||||
@ -327,27 +332,72 @@ async def dashboard(request: Request):
|
|||||||
total = sum(counts.values())
|
total = sum(counts.values())
|
||||||
|
|
||||||
recent_rows = (
|
recent_rows = (
|
||||||
sess.query(Source)
|
sess.query(Source).order_by(Source.ingested_at.desc()).limit(10).all()
|
||||||
.order_by(Source.ingested_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
recent = [_source_to_dict(s) for s in recent_rows]
|
recent = [_source_to_dict(s) for s in recent_rows]
|
||||||
|
|
||||||
settings_row = get_settings(sess)
|
settings_summary = _settings_to_dict(get_settings(sess))
|
||||||
settings_summary = _settings_to_dict(settings_row)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return {
|
||||||
request,
|
|
||||||
"dashboard.html",
|
|
||||||
{
|
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
"total": total,
|
"total": total,
|
||||||
"recent": recent,
|
"recent": recent,
|
||||||
"statuses_in_order": [s.value for s in SourceStatus],
|
"statuses_in_order": [s.value for s in SourceStatus],
|
||||||
"settings": settings_summary,
|
"settings": settings_summary,
|
||||||
},
|
"domains": DOMAINS,
|
||||||
|
"add_flash": add_flash,
|
||||||
|
"add_error": add_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
ctx = _build_dashboard_context(sess)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "dashboard.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/sources/add", response_class=HTMLResponse)
|
||||||
|
async def sources_add(
|
||||||
|
request: Request,
|
||||||
|
url: str = Form(...),
|
||||||
|
domain: str = Form("development"),
|
||||||
|
title: Optional[str] = Form(None),
|
||||||
|
focus: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
"""HTMX endpoint — queue a source, re-render the dashboard body."""
|
||||||
|
db = get_database()
|
||||||
|
add_flash: Optional[str] = None
|
||||||
|
add_error: Optional[str] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.session() as sess:
|
||||||
|
result = add_source(
|
||||||
|
sess, url=url, domain=domain, focus=focus, title=title
|
||||||
)
|
)
|
||||||
|
# Snapshot the message inside the session — `result.source`
|
||||||
|
# detaches on commit.
|
||||||
|
if result.added:
|
||||||
|
add_flash = (
|
||||||
|
f"Queued {result.source.source_type.value}: {result.source.url}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
add_flash = (
|
||||||
|
f"Already queued (id={result.source.id}, "
|
||||||
|
f"status={result.source.status.value})"
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
add_error = str(exc)
|
||||||
|
|
||||||
|
with db.session() as sess:
|
||||||
|
ctx = _build_dashboard_context(
|
||||||
|
sess, add_flash=add_flash, add_error=add_error
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "_dashboard_body.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
215
src/second_brain/web/templates/_dashboard_body.html
Normal file
215
src/second_brain/web/templates/_dashboard_body.html
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
{# HTMX-replaceable dashboard body. POST /sources/add re-renders this
|
||||||
|
partial; hx-target="#dashboard-body" + hx-swap="outerHTML" keeps the
|
||||||
|
wrapping div in place for the next swap. #}
|
||||||
|
{% 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 id="dashboard-body">
|
||||||
|
|
||||||
|
<!-- Add source -->
|
||||||
|
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Add to queue
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-400">video on a known host (YouTube / Vimeo) → tower; otherwise treated as an article</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if add_flash %}
|
||||||
|
<div class="mb-3 px-3 py-2 rounded bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||||
|
✓ {{ add_flash }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if add_error %}
|
||||||
|
<div class="mb-3 px-3 py-2 rounded bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||||
|
✗ {{ add_error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form hx-post="/sources/add"
|
||||||
|
hx-target="#dashboard-body"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
|
||||||
|
|
||||||
|
<div class="md:col-span-6">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">URL</label>
|
||||||
|
<input type="url" name="url" required autofocus
|
||||||
|
placeholder="https://…"
|
||||||
|
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">Domain</label>
|
||||||
|
<select name="domain"
|
||||||
|
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">
|
||||||
|
{% for d in domains %}
|
||||||
|
<option value="{{ d }}" {% if d == "development" %}selected{% endif %}>{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Title <span class="text-gray-400">(optional)</span></label>
|
||||||
|
<input type="text" name="title"
|
||||||
|
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-1">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full px-4 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 transition-colors">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-12">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Focus <span class="text-gray-400">(optional — narrows the extraction prompt)</span></label>
|
||||||
|
<input type="text" name="focus"
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
@ -3,156 +3,10 @@
|
|||||||
{% block title %}Dashboard — second-brain{% endblock %}
|
{% block title %}Dashboard — second-brain{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h2 class="text-2xl font-semibold text-gray-800">Dashboard</h2>
|
<h2 class="text-2xl font-semibold text-gray-800">Dashboard</h2>
|
||||||
<span class="text-sm text-gray-500">total sources: {{ total }}</span>
|
<span class="text-sm text-gray-500">total sources: {{ total }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pipeline overview -->
|
{% include "_dashboard_body.html" %}
|
||||||
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user