Compare commits
3 Commits
0d9ebc9cc7
...
7eafdf3e03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eafdf3e03 | ||
|
|
c92a40bce1 | ||
|
|
09efbb5965 |
70
alembic/versions/7b3e8a5c1f29_v2_pipeline_settings.py
Normal file
70
alembic/versions/7b3e8a5c1f29_v2_pipeline_settings.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""v2 second_brain: pipeline_settings table
|
||||||
|
|
||||||
|
Revision ID: 7b3e8a5c1f29
|
||||||
|
Revises: 4a1e2f6c9d10
|
||||||
|
Create Date: 2026-05-25 00:00:00.000000
|
||||||
|
|
||||||
|
Adds a single-row `pipeline_settings` table that the web dashboard edits
|
||||||
|
and the workers (current dev-side scheduler + future tower-side transcribe
|
||||||
|
worker) read at the start of each run. The transcription_* fields persist
|
||||||
|
now but are only consumed once the transcribe worker exists — the
|
||||||
|
extraction_* fields are wired into the existing scheduler in this commit.
|
||||||
|
|
||||||
|
Single-row enforcement: PK is fixed at `id=1` with a CHECK constraint, so
|
||||||
|
INSERT-as-upsert-by-PK keeps the table free of stale rows.
|
||||||
|
|
||||||
|
Schema/permission notes mirror the v1 migration: lovebug lacks CREATE on
|
||||||
|
the petalbrain database, so env.py has already bootstrapped the schema —
|
||||||
|
this migration just SETs search_path and emits DDL inside it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "7b3e8a5c1f29"
|
||||||
|
down_revision: str | Sequence[str] | None = "4a1e2f6c9d10"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("SET search_path TO second_brain")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE pipeline_settings (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
transcription_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
transcription_active_hours_start TIME,
|
||||||
|
transcription_active_hours_end TIME,
|
||||||
|
transcription_max_concurrent_gpu_jobs INTEGER NOT NULL DEFAULT 1,
|
||||||
|
transcription_max_video_length_seconds INTEGER,
|
||||||
|
transcription_max_items_per_run INTEGER NOT NULL DEFAULT 10,
|
||||||
|
extraction_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
extraction_active_hours_start TIME,
|
||||||
|
extraction_active_hours_end TIME,
|
||||||
|
extraction_max_items_per_run INTEGER NOT NULL DEFAULT 20,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||||
|
CONSTRAINT pipeline_settings_singleton CHECK (id = 1)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed the singleton row with the table's column defaults. Idempotent
|
||||||
|
# via ON CONFLICT so re-running this migration on a partly-applied DB
|
||||||
|
# (or a manual cherry-pick) is safe.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pipeline_settings (id) VALUES (1)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("SET search_path TO second_brain")
|
||||||
|
op.execute("DROP TABLE IF EXISTS pipeline_settings")
|
||||||
@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"psycopg-pool>=3.2",
|
"psycopg-pool>=3.2",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
"sqlalchemy>=2.0.0",
|
"sqlalchemy>=2.0.0",
|
||||||
"tomli>=2.0.0",
|
"tomli>=2.0.0",
|
||||||
"trafilatura>=1.9.0",
|
"trafilatura>=1.9.0",
|
||||||
|
|||||||
@ -14,11 +14,12 @@ Tables:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, time, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
|
Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@ -26,6 +27,7 @@ from sqlalchemy import (
|
|||||||
MetaData,
|
MetaData,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
Time,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@ -193,6 +195,64 @@ class WikiPage(Base):
|
|||||||
return f"WikiPage(id={self.id}, vault_path={self.vault_path!r})"
|
return f"WikiPage(id={self.id}, vault_path={self.vault_path!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineSettings(Base):
|
||||||
|
"""Single-row config the web dashboard edits and workers read.
|
||||||
|
|
||||||
|
The transcription_* fields persist now and will be honored by the
|
||||||
|
tower-side transcribe worker once it exists; the extraction_* fields
|
||||||
|
are already wired into the dev-side scheduler. The row is pinned to
|
||||||
|
`id=1` by a DB-level CHECK so upserts-by-PK keep the table singleton.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "pipeline_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
|
|
||||||
|
# Transcription (consumed by the tower-side worker — not yet built).
|
||||||
|
transcription_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
transcription_active_hours_start: Mapped[Optional[time]] = mapped_column(
|
||||||
|
Time, nullable=True
|
||||||
|
)
|
||||||
|
transcription_active_hours_end: Mapped[Optional[time]] = mapped_column(
|
||||||
|
Time, nullable=True
|
||||||
|
)
|
||||||
|
transcription_max_concurrent_gpu_jobs: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=1
|
||||||
|
)
|
||||||
|
transcription_max_video_length_seconds: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True
|
||||||
|
)
|
||||||
|
transcription_max_items_per_run: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extraction (consumed by the dev-side scheduler today).
|
||||||
|
extraction_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
extraction_active_hours_start: Mapped[Optional[time]] = mapped_column(
|
||||||
|
Time, nullable=True
|
||||||
|
)
|
||||||
|
extraction_active_hours_end: Mapped[Optional[time]] = mapped_column(
|
||||||
|
Time, nullable=True
|
||||||
|
)
|
||||||
|
extraction_max_items_per_run: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=20
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"PipelineSettings(transcription_enabled={self.transcription_enabled}, "
|
||||||
|
f"extraction_enabled={self.extraction_enabled})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"SCHEMA",
|
"SCHEMA",
|
||||||
@ -200,6 +260,7 @@ __all__ = [
|
|||||||
"SourceStatus",
|
"SourceStatus",
|
||||||
"SourceType",
|
"SourceType",
|
||||||
"Extraction",
|
"Extraction",
|
||||||
|
"PipelineSettings",
|
||||||
"WikiPage",
|
"WikiPage",
|
||||||
"utcnow",
|
"utcnow",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from datetime import datetime, time as dtime
|
|||||||
from second_brain.config import Config
|
from second_brain.config import Config
|
||||||
from second_brain.database import get_database
|
from second_brain.database import get_database
|
||||||
from second_brain.models import Source, SourceStatus, utcnow
|
from second_brain.models import Source, SourceStatus, utcnow
|
||||||
|
from second_brain.settings_store import get_settings, in_active_window
|
||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
class Scheduler:
|
||||||
@ -51,6 +52,38 @@ class Scheduler:
|
|||||||
from second_brain.models import Extraction
|
from second_brain.models import Extraction
|
||||||
|
|
||||||
db = get_database()
|
db = get_database()
|
||||||
|
|
||||||
|
# Pull the DB-side runtime knobs once at the top of the run. The
|
||||||
|
# dashboard edits these; settings.toml is the static fallback. The
|
||||||
|
# values are snapshotted for the duration of this run so a mid-run
|
||||||
|
# toggle doesn't half-apply (consistent with the way max_calls is
|
||||||
|
# tracked locally).
|
||||||
|
with db.session() as sess:
|
||||||
|
settings = get_settings(sess)
|
||||||
|
extraction_enabled = settings.extraction_enabled
|
||||||
|
db_window = (
|
||||||
|
settings.extraction_active_hours_start,
|
||||||
|
settings.extraction_active_hours_end,
|
||||||
|
)
|
||||||
|
max_items = int(settings.extraction_max_items_per_run or 0)
|
||||||
|
|
||||||
|
if not extraction_enabled:
|
||||||
|
print("[Scheduler] extraction_enabled=false in pipeline_settings. Skipping run.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# DB window (if set) overrides the static settings.toml window. If
|
||||||
|
# only one side is set, treat the other as unbounded (no constraint).
|
||||||
|
if db_window[0] is not None or db_window[1] is not None:
|
||||||
|
self.window_start = db_window[0] or dtime(0, 0)
|
||||||
|
self.window_end = db_window[1] or dtime(23, 59)
|
||||||
|
|
||||||
|
if not in_active_window(datetime.now().time(), db_window[0], db_window[1]):
|
||||||
|
print(
|
||||||
|
f"[Scheduler] outside extraction active window "
|
||||||
|
f"{_fmt_opt(db_window[0])}–{_fmt_opt(db_window[1])}. Skipping run."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
assembler = ContextAssembler(self.config)
|
assembler = ContextAssembler(self.config)
|
||||||
ex_cfg = self.config.extractor
|
ex_cfg = self.config.extractor
|
||||||
engine = ExtractionEngine(
|
engine = ExtractionEngine(
|
||||||
@ -74,10 +107,14 @@ class Scheduler:
|
|||||||
print(
|
print(
|
||||||
f"[Scheduler] backend={self.backend} window "
|
f"[Scheduler] backend={self.backend} window "
|
||||||
f"{_fmt(self.window_start)}–{_fmt(self.window_end)} cap {cap_desc} "
|
f"{_fmt(self.window_start)}–{_fmt(self.window_end)} cap {cap_desc} "
|
||||||
f"gap {self.min_gap_seconds}s"
|
f"gap {self.min_gap_seconds}s max_items={max_items or '∞'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
while self._in_window():
|
while self._in_window():
|
||||||
|
# DB-side hard cap on items per run (0 = unlimited).
|
||||||
|
if max_items and processed >= max_items:
|
||||||
|
print(f"[Scheduler] reached extraction_max_items_per_run={max_items}. Done.")
|
||||||
|
break
|
||||||
# Reset hourly buckets
|
# Reset hourly buckets
|
||||||
elapsed = time.time() - hour_start
|
elapsed = time.time() - hour_start
|
||||||
if elapsed >= 3600:
|
if elapsed >= 3600:
|
||||||
@ -191,4 +228,8 @@ def _fmt(t: dtime) -> str:
|
|||||||
return t.strftime("%H:%M")
|
return t.strftime("%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_opt(t: dtime | None) -> str:
|
||||||
|
return t.strftime("%H:%M") if t is not None else "—"
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Scheduler"]
|
__all__ = ["Scheduler"]
|
||||||
|
|||||||
121
src/second_brain/settings_store.py
Normal file
121
src/second_brain/settings_store.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""Helpers for the singleton `pipeline_settings` row.
|
||||||
|
|
||||||
|
The web dashboard edits this row; workers read it at the start of each
|
||||||
|
run to decide whether to do anything and how much. DB values override
|
||||||
|
the static defaults in `settings.toml`.
|
||||||
|
|
||||||
|
All functions take a SQLAlchemy `Session` so the caller owns the
|
||||||
|
transaction boundary — matches the rest of the codebase, which keeps
|
||||||
|
session lifecycle inside `with db.session()` blocks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from second_brain.models import PipelineSettings, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
# Field names allowed in `update_settings`. Anything else is rejected so a
|
||||||
|
# malicious form post can't toggle id/updated_at/etc.
|
||||||
|
_EDITABLE_FIELDS: tuple[str, ...] = (
|
||||||
|
"transcription_enabled",
|
||||||
|
"transcription_active_hours_start",
|
||||||
|
"transcription_active_hours_end",
|
||||||
|
"transcription_max_concurrent_gpu_jobs",
|
||||||
|
"transcription_max_video_length_seconds",
|
||||||
|
"transcription_max_items_per_run",
|
||||||
|
"extraction_enabled",
|
||||||
|
"extraction_active_hours_start",
|
||||||
|
"extraction_active_hours_end",
|
||||||
|
"extraction_max_items_per_run",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(sess: Session) -> PipelineSettings:
|
||||||
|
"""Return the singleton settings row, creating it on first call.
|
||||||
|
|
||||||
|
The v2 migration seeds the row with all DB-side defaults, so this
|
||||||
|
"create on miss" path only kicks in when somebody bootstrapped the
|
||||||
|
schema without applying the migration (e.g. an unconfigured test
|
||||||
|
using `Base.metadata.create_all`).
|
||||||
|
"""
|
||||||
|
row = sess.get(PipelineSettings, 1)
|
||||||
|
if row is None:
|
||||||
|
row = PipelineSettings(id=1)
|
||||||
|
sess.add(row)
|
||||||
|
sess.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_settings(sess: Session, **fields: Any) -> PipelineSettings:
|
||||||
|
"""Apply a partial update to the singleton row.
|
||||||
|
|
||||||
|
Unknown keys raise `KeyError`. Empty-string values for nullable fields
|
||||||
|
are normalised to None so the HTML form's blank inputs (active hours,
|
||||||
|
video-length cap) round-trip as NULL rather than as the strings "".
|
||||||
|
"""
|
||||||
|
for k in fields:
|
||||||
|
if k not in _EDITABLE_FIELDS:
|
||||||
|
raise KeyError(f"Unknown pipeline_settings field: {k!r}")
|
||||||
|
|
||||||
|
row = get_settings(sess)
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v == "":
|
||||||
|
v = None
|
||||||
|
setattr(row, k, v)
|
||||||
|
row.updated_at = utcnow()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_or_none(raw: str | None) -> time | None:
|
||||||
|
"""Parse an HTML <input type=time> value (HH:MM) into a `time`.
|
||||||
|
|
||||||
|
Empty / missing values map to None — `update_settings` will store
|
||||||
|
NULL, which workers interpret as "no window, always active".
|
||||||
|
"""
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return None
|
||||||
|
parts = raw.split(":")
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError(f"Could not parse time: {raw!r}")
|
||||||
|
h, m = int(parts[0]), int(parts[1])
|
||||||
|
return time(h, m)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_int_or_none(raw: str | None) -> int | None:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return None
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def in_active_window(now_t: time, start: time | None, end: time | None) -> bool:
|
||||||
|
"""Return True if `now_t` lies in the [start, end] window.
|
||||||
|
|
||||||
|
Either bound being None means "no constraint on that side", which
|
||||||
|
matches the dashboard's "leave blank = always active" semantics.
|
||||||
|
Handles overnight windows (start > end) by treating them as the
|
||||||
|
complement that crosses midnight.
|
||||||
|
"""
|
||||||
|
if start is None and end is None:
|
||||||
|
return True
|
||||||
|
if start is None:
|
||||||
|
return now_t <= end # type: ignore[operator]
|
||||||
|
if end is None:
|
||||||
|
return now_t >= start
|
||||||
|
if start <= end:
|
||||||
|
return start <= now_t <= end
|
||||||
|
# crosses midnight
|
||||||
|
return now_t >= start or now_t <= end
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_settings",
|
||||||
|
"update_settings",
|
||||||
|
"parse_time_or_none",
|
||||||
|
"parse_int_or_none",
|
||||||
|
"in_active_window",
|
||||||
|
]
|
||||||
@ -14,14 +14,21 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
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.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from second_brain.config import DOMAINS
|
from second_brain.config import DOMAINS
|
||||||
from second_brain.database import get_database
|
from second_brain.database import get_database
|
||||||
from second_brain.models import Extraction, Source, SourceStatus
|
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
|
# App setup
|
||||||
@ -295,3 +302,176 @@ def _source_to_response(s: Source) -> SourceResponse:
|
|||||||
ingested_at=s.ingested_at.isoformat() if s.ingested_at else "",
|
ingested_at=s.ingested_at.isoformat() if s.ingested_at else "",
|
||||||
has_extraction=s.extraction is not None,
|
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>
|
<p class="text-indigo-200 text-sm mt-0.5">personal knowledge pipeline</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex gap-4 text-sm font-medium">
|
<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="/" class="hover:text-indigo-200 transition-colors">Sources</a>
|
||||||
<a href="/queue" class="hover:text-indigo-200 transition-colors">Queue</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytz"
|
name = "pytz"
|
||||||
version = "2026.2"
|
version = "2026.2"
|
||||||
@ -1584,6 +1593,7 @@ dependencies = [
|
|||||||
{ name = "psycopg-pool" },
|
{ name = "psycopg-pool" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "tomli" },
|
{ name = "tomli" },
|
||||||
{ name = "trafilatura" },
|
{ name = "trafilatura" },
|
||||||
@ -1617,6 +1627,7 @@ requires-dist = [
|
|||||||
{ name = "psycopg-pool", specifier = ">=3.2" },
|
{ name = "psycopg-pool", specifier = ">=3.2" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||||
{ name = "tomli", specifier = ">=2.0.0" },
|
{ name = "tomli", specifier = ">=2.0.0" },
|
||||||
{ name = "trafilatura", specifier = ">=1.9.0" },
|
{ name = "trafilatura", specifier = ">=1.9.0" },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user