Feat/decomposition #19
20 changed files with 199 additions and 85 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -21,6 +21,10 @@ jobs:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- uses: astral-sh/setup-uv@v4
|
- uses: astral-sh/setup-uv@v4
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check
|
||||||
|
- name: Test
|
||||||
|
run: uv run pytest
|
||||||
- name: Semantic Release
|
- name: Semantic Release
|
||||||
id: release
|
id: release
|
||||||
run: uv run semantic-release publish
|
run: uv run semantic-release publish
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, NoReturn
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -215,7 +215,7 @@ async def execute_manual_run(
|
||||||
raise_manual_failed(exc=exc, user_id=user_id)
|
raise_manual_failed(exc=exc, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def raise_manual_blocked_safety(*, exc, user_id: int) -> None:
|
def raise_manual_blocked_safety(*, exc, user_id: int) -> NoReturn:
|
||||||
structured_log(
|
structured_log(
|
||||||
logger,
|
logger,
|
||||||
"info",
|
"info",
|
||||||
|
|
@ -233,7 +233,7 @@ def raise_manual_blocked_safety(*, exc, user_id: int) -> None:
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def raise_manual_failed(*, exc: Exception, user_id: int) -> None:
|
def raise_manual_failed(*, exc: Exception, user_id: int) -> NoReturn:
|
||||||
structured_log(
|
structured_log(
|
||||||
logger,
|
logger,
|
||||||
"exception",
|
"exception",
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AdminUsersListEnvelope(BaseModel):
|
||||||
|
|
||||||
class AdminUserCreateRequest(BaseModel):
|
class AdminUserCreateRequest(BaseModel):
|
||||||
email: str = Field(max_length=254)
|
email: str = Field(max_length=254)
|
||||||
password: str = Field(max_length=128)
|
password: str = Field(min_length=8, max_length=128)
|
||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
@ -55,7 +55,7 @@ class AdminUserEnvelope(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AdminResetPasswordRequest(BaseModel):
|
class AdminResetPasswordRequest(BaseModel):
|
||||||
new_password: str = Field(max_length=128)
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@ class AdminScholarHttpSettingsData(BaseModel):
|
||||||
user_agent: str
|
user_agent: str
|
||||||
rotate_user_agent: bool
|
rotate_user_agent: bool
|
||||||
accept_language: str
|
accept_language: str
|
||||||
cookie: str
|
cookie: str | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ class AdminScholarHttpSettingsUpdateRequest(BaseModel):
|
||||||
user_agent: str
|
user_agent: str
|
||||||
rotate_user_agent: bool
|
rotate_user_agent: bool
|
||||||
accept_language: str
|
accept_language: str
|
||||||
cookie: str
|
cookie: str | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class CsrfBootstrapEnvelope(BaseModel):
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: str = Field(max_length=254)
|
email: str = Field(max_length=254)
|
||||||
password: str = Field(max_length=128)
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -66,8 +66,8 @@ class LoginEnvelope(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
current_password: str = Field(max_length=128)
|
current_password: str = Field(min_length=8, max_length=128)
|
||||||
new_password: str = Field(max_length=128)
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
confirm_password: str = Field(max_length=128)
|
confirm_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
|
||||||
20
app/main.py
20
app/main.py
|
|
@ -81,6 +81,26 @@ async def lifespan(_: FastAPI):
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE publication_pdf_jobs SET status = 'queued'"
|
||||||
|
" WHERE status = 'running'"
|
||||||
|
" AND (last_attempt_at IS NULL OR last_attempt_at < NOW() - INTERVAL '10 minutes')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
structured_log(logger, "info", "app.startup_stuck_pdf_jobs_recovered")
|
||||||
|
except Exception as exc:
|
||||||
|
structured_log(
|
||||||
|
logger,
|
||||||
|
"error",
|
||||||
|
"app.startup_stuck_pdf_jobs_recovery_failed",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
await scheduler_service.start()
|
await scheduler_service.start()
|
||||||
yield
|
yield
|
||||||
await scheduler_service.stop()
|
await scheduler_service.stop()
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,10 @@ async def _run_serialized_fetch(
|
||||||
source_path: str,
|
source_path: str,
|
||||||
) -> tuple[httpx.Response, bool]:
|
) -> tuple[httpx.Response, bool]:
|
||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as lock_session:
|
||||||
|
await _acquire_arxiv_lock(lock_session)
|
||||||
|
try:
|
||||||
async with session_factory() as db_session, db_session.begin():
|
async with session_factory() as db_session, db_session.begin():
|
||||||
await _acquire_arxiv_lock(db_session)
|
|
||||||
runtime_state = await _load_runtime_state_for_update(db_session)
|
runtime_state = await _load_runtime_state_for_update(db_session)
|
||||||
wait_seconds = await _wait_for_allowed_slot_or_raise(
|
wait_seconds = await _wait_for_allowed_slot_or_raise(
|
||||||
runtime_state,
|
runtime_state,
|
||||||
|
|
@ -88,6 +90,9 @@ async def _run_serialized_fetch(
|
||||||
response_status=int(response.status_code),
|
response_status=int(response.status_code),
|
||||||
source_path=source_path,
|
source_path=source_path,
|
||||||
)
|
)
|
||||||
|
cooldown_until_value = runtime_state.cooldown_until
|
||||||
|
finally:
|
||||||
|
await _release_arxiv_lock(lock_session)
|
||||||
structured_log(
|
structured_log(
|
||||||
logger,
|
logger,
|
||||||
"info",
|
"info",
|
||||||
|
|
@ -95,7 +100,7 @@ async def _run_serialized_fetch(
|
||||||
status_code=int(response.status_code),
|
status_code=int(response.status_code),
|
||||||
wait_seconds=wait_seconds,
|
wait_seconds=wait_seconds,
|
||||||
cooldown_remaining_seconds=_cooldown_remaining_seconds(
|
cooldown_remaining_seconds=_cooldown_remaining_seconds(
|
||||||
runtime_state.cooldown_until, now_utc=datetime.now(UTC)
|
cooldown_until_value, now_utc=datetime.now(UTC)
|
||||||
),
|
),
|
||||||
source_path=source_path,
|
source_path=source_path,
|
||||||
)
|
)
|
||||||
|
|
@ -104,7 +109,17 @@ async def _run_serialized_fetch(
|
||||||
|
|
||||||
async def _acquire_arxiv_lock(db_session: AsyncSession) -> None:
|
async def _acquire_arxiv_lock(db_session: AsyncSession) -> None:
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
text("SELECT pg_advisory_xact_lock(:namespace, :lock_key)"),
|
text("SELECT pg_advisory_lock(:namespace, :lock_key)"),
|
||||||
|
{
|
||||||
|
"namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE,
|
||||||
|
"lock_key": ARXIV_RATE_LIMIT_LOCK_KEY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _release_arxiv_lock(db_session: AsyncSession) -> None:
|
||||||
|
await db_session.execute(
|
||||||
|
text("SELECT pg_advisory_unlock(:namespace, :lock_key)"),
|
||||||
{
|
{
|
||||||
"namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE,
|
"namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE,
|
||||||
"lock_key": ARXIV_RATE_LIMIT_LOCK_KEY,
|
"lock_key": ARXIV_RATE_LIMIT_LOCK_KEY,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ if TYPE_CHECKING:
|
||||||
from app.services.publications.types import PublicationListItem, UnreadPublicationItem
|
from app.services.publications.types import PublicationListItem, UnreadPublicationItem
|
||||||
|
|
||||||
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||||
NON_ALNUM_RE = re.compile(r"[^a-z0-9\\s]+")
|
NON_ALNUM_RE = re.compile(r"[^a-z0-9\s]+")
|
||||||
STOP_WORDS = {"the", "and", "for", "with", "from", "method", "study", "analysis"}
|
STOP_WORDS = {"the", "and", "for", "with", "from", "method", "study", "analysis"}
|
||||||
_RATE_LOCK = threading.Lock()
|
_RATE_LOCK = threading.Lock()
|
||||||
_LAST_REQUEST_AT = 0.0
|
_LAST_REQUEST_AT = 0.0
|
||||||
|
|
@ -37,6 +37,7 @@ def _rate_limit_wait(min_interval_seconds: float) -> None:
|
||||||
remaining = interval - elapsed
|
remaining = interval - elapsed
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
time.sleep(remaining)
|
time.sleep(remaining)
|
||||||
|
with _RATE_LOCK:
|
||||||
_LAST_REQUEST_AT = time.monotonic()
|
_LAST_REQUEST_AT = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,10 @@ async def _complete_job(
|
||||||
|
|
||||||
|
|
||||||
async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None:
|
async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None:
|
||||||
|
from sqlalchemy.orm import make_transient
|
||||||
|
|
||||||
await db_session.rollback()
|
await db_session.rollback()
|
||||||
|
make_transient(job)
|
||||||
job.status = REPAIR_STATUS_FAILED
|
job.status = REPAIR_STATUS_FAILED
|
||||||
job.error_text = str(error)
|
job.error_text = str(error)
|
||||||
job.finished_at = _utcnow()
|
job.finished_at = _utcnow()
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,7 @@ class ScholarIngestionService:
|
||||||
if run_to_fail:
|
if run_to_fail:
|
||||||
run_to_fail.status = RunStatus.FAILED
|
run_to_fail.status = RunStatus.FAILED
|
||||||
run_to_fail.end_dt = datetime.now(UTC)
|
run_to_fail.end_dt = datetime.now(UTC)
|
||||||
|
run_to_fail.error_log = run_to_fail.error_log or {}
|
||||||
run_to_fail.error_log["terminal_exception"] = str(exc)
|
run_to_fail.error_log["terminal_exception"] = str(exc)
|
||||||
await cleanup_session.commit()
|
await cleanup_session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@ async def upsert_profile_publications(
|
||||||
seen_publication_ids: set[int] = set()
|
seen_publication_ids: set[int] = set()
|
||||||
discovered_count = 0
|
discovered_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
for candidate in publications:
|
for candidate in publications:
|
||||||
publication = await resolve_publication(db_session, candidate)
|
publication = await resolve_publication(db_session, candidate)
|
||||||
if publication.id in seen_publication_ids:
|
if publication.id in seen_publication_ids:
|
||||||
|
|
@ -201,7 +202,7 @@ async def upsert_profile_publications(
|
||||||
db_session.add(link)
|
db_session.add(link)
|
||||||
discovered_count += 1
|
discovered_count += 1
|
||||||
|
|
||||||
await commit_discovered_publication(
|
await flush_discovered_publication(
|
||||||
db_session,
|
db_session,
|
||||||
run=run,
|
run=run,
|
||||||
scholar=scholar,
|
scholar=scholar,
|
||||||
|
|
@ -211,10 +212,15 @@ async def upsert_profile_publications(
|
||||||
if not scholar.baseline_completed:
|
if not scholar.baseline_completed:
|
||||||
scholar.baseline_completed = True
|
scholar.baseline_completed = True
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await db_session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
return discovered_count
|
return discovered_count
|
||||||
|
|
||||||
|
|
||||||
async def commit_discovered_publication(
|
async def flush_discovered_publication(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
run: CrawlRun,
|
run: CrawlRun,
|
||||||
|
|
@ -222,7 +228,7 @@ async def commit_discovered_publication(
|
||||||
publication: Publication,
|
publication: Publication,
|
||||||
) -> None:
|
) -> None:
|
||||||
run.new_pub_count = int(run.new_pub_count or 0) + 1
|
run.new_pub_count = int(run.new_pub_count or 0) + 1
|
||||||
await db_session.commit()
|
await db_session.flush()
|
||||||
await run_events.publish(
|
await run_events.publish(
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
event_type="publication_discovered",
|
event_type="publication_discovered",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ async def list_due_jobs(
|
||||||
IngestionQueueItem.id.asc(),
|
IngestionQueueItem.id.asc(),
|
||||||
)
|
)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
)
|
)
|
||||||
rows = list(result.scalars().all())
|
rows = list(result.scalars().all())
|
||||||
jobs: list[ContinuationQueueJob] = []
|
jobs: list[ContinuationQueueJob] = []
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,5 @@ def queued_job(
|
||||||
status=PDF_STATUS_QUEUED,
|
status=PDF_STATUS_QUEUED,
|
||||||
queued_at=now,
|
queued_at=now,
|
||||||
last_requested_by_user_id=user_id,
|
last_requested_by_user_id=user_id,
|
||||||
|
attempt_count=0,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ async def _mark_attempt_started(
|
||||||
db_session.add(job)
|
db_session.add(job)
|
||||||
job.status = PDF_STATUS_RUNNING
|
job.status = PDF_STATUS_RUNNING
|
||||||
job.last_attempt_at = utcnow()
|
job.last_attempt_at = utcnow()
|
||||||
job.attempt_count = int(job.attempt_count) + 1
|
job.attempt_count = int(job.attempt_count or 0) + 1
|
||||||
db_session.add(
|
db_session.add(
|
||||||
event_row(
|
event_row(
|
||||||
publication_id=publication_id,
|
publication_id=publication_id,
|
||||||
|
|
@ -240,6 +240,26 @@ async def _run_resolution_task(
|
||||||
detail="Stopping PDF resolution batch — OpenAlex daily budget exhausted",
|
detail="Stopping PDF resolution batch — OpenAlex daily budget exhausted",
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
except Exception:
|
||||||
|
structured_log(
|
||||||
|
logger,
|
||||||
|
"exception",
|
||||||
|
"pdf_queue.row_failed",
|
||||||
|
publication_id=row.publication_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await _persist_outcome(
|
||||||
|
publication_id=row.publication_id,
|
||||||
|
user_id=user_id,
|
||||||
|
outcome=_failed_outcome(row=row),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
structured_log(
|
||||||
|
logger,
|
||||||
|
"exception",
|
||||||
|
"pdf_queue.row_fail_persist_error",
|
||||||
|
publication_id=row.publication_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _register_task(task: asyncio.Task[None]) -> None:
|
def _register_task(task: asyncio.Task[None]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from app.logging_utils import structured_log
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SUBSCRIBER_QUEUE_MAXSIZE = 256
|
||||||
|
|
||||||
|
|
||||||
class RunEventPublisher:
|
class RunEventPublisher:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
@ -17,7 +19,7 @@ class RunEventPublisher:
|
||||||
def subscribe(self, run_id: int) -> asyncio.Queue:
|
def subscribe(self, run_id: int) -> asyncio.Queue:
|
||||||
if run_id not in self._subscribers:
|
if run_id not in self._subscribers:
|
||||||
self._subscribers[run_id] = set()
|
self._subscribers[run_id] = set()
|
||||||
queue: asyncio.Queue[Any] = asyncio.Queue()
|
queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_MAXSIZE)
|
||||||
self._subscribers[run_id].add(queue)
|
self._subscribers[run_id].add(queue)
|
||||||
structured_log(
|
structured_log(
|
||||||
logger,
|
logger,
|
||||||
|
|
@ -51,6 +53,10 @@ class RunEventPublisher:
|
||||||
"runs.event_subscriber_queue_full",
|
"runs.event_subscriber_queue_full",
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
)
|
)
|
||||||
|
self._subscribers[run_id].discard(queue)
|
||||||
|
|
||||||
|
async def publish_run_complete(self, run_id: int) -> None:
|
||||||
|
await self.publish(run_id, "run_complete", {})
|
||||||
|
|
||||||
|
|
||||||
run_events = RunEventPublisher()
|
run_events = RunEventPublisher()
|
||||||
|
|
@ -62,8 +68,11 @@ async def event_generator(run_id: int) -> AsyncGenerator[str, None]:
|
||||||
while True:
|
while True:
|
||||||
# Wait for a new event
|
# Wait for a new event
|
||||||
message = await queue.get()
|
message = await queue.get()
|
||||||
# Server-Sent Events format: "event: <type>\ndata: <json>\n\n"
|
|
||||||
event_type = message["type"]
|
event_type = message["type"]
|
||||||
|
if event_type == "run_complete":
|
||||||
|
yield f"event: {event_type}\ndata: {{}}\n\n"
|
||||||
|
break
|
||||||
|
# Server-Sent Events format: "event: <type>\ndata: <json>\n\n"
|
||||||
data_str = json.dumps(message["data"])
|
data_str = json.dumps(message["data"])
|
||||||
yield f"event: {event_type}\ndata: {data_str}\n\n"
|
yield f"event: {event_type}\ndata: {data_str}\n\n"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ function buildItem(overrides: Partial<PublicationItem> = {}): PublicationItem {
|
||||||
citation_count: 42,
|
citation_count: 42,
|
||||||
pub_url: "https://example.com/pub",
|
pub_url: "https://example.com/pub",
|
||||||
pdf_url: null,
|
pdf_url: null,
|
||||||
pdf_status: null,
|
pdf_status: "untracked",
|
||||||
is_read: false,
|
is_read: false,
|
||||||
is_favorite: false,
|
is_favorite: false,
|
||||||
is_new_in_latest_run: true,
|
is_new_in_latest_run: true,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, onScopeDispose, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
listPublications,
|
listPublications,
|
||||||
|
|
@ -271,6 +271,10 @@ export function usePublicationData() {
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Run-triggered refresh watcher ---
|
// --- Run-triggered refresh watcher ---
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,8 @@ async function refreshForSection(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (props.section === SECTION_REPAIRS) {
|
if (props.section === SECTION_REPAIRS) {
|
||||||
await Promise.all([
|
await usersRef.value?.load();
|
||||||
usersRef.value?.load(),
|
await repairsRef.value?.load();
|
||||||
repairsRef.value?.load(),
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import {
|
import {
|
||||||
listAdminPdfQueue,
|
listAdminPdfQueue,
|
||||||
requeueAdminPdfLookup,
|
requeueAdminPdfLookup,
|
||||||
|
|
@ -15,7 +16,7 @@ import {
|
||||||
} from "@/features/admin_dbops";
|
} from "@/features/admin_dbops";
|
||||||
import { useRequestState } from "@/composables/useRequestState";
|
import { useRequestState } from "@/composables/useRequestState";
|
||||||
|
|
||||||
const { clearAlerts, assignError, setSuccess } = useRequestState();
|
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
||||||
|
|
||||||
const refreshingPdfQueue = ref(false);
|
const refreshingPdfQueue = ref(false);
|
||||||
const requeueingPublicationId = ref<number | null>(null);
|
const requeueingPublicationId = ref<number | null>(null);
|
||||||
|
|
@ -131,6 +132,16 @@ defineExpose({ load });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<section class="grid gap-4">
|
||||||
|
<RequestStateAlerts
|
||||||
|
:success-message="successMessage"
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:error-request-id="errorRequestId"
|
||||||
|
success-title="PDF queue operation complete"
|
||||||
|
error-title="PDF queue operation failed"
|
||||||
|
@dismiss-success="successMessage = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppCard class="space-y-3">
|
<AppCard class="space-y-3">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|
@ -200,4 +211,5 @@ defineExpose({ load });
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import {
|
import {
|
||||||
dropAllPublications,
|
dropAllPublications,
|
||||||
listAdminDbRepairJobs,
|
listAdminDbRepairJobs,
|
||||||
|
|
@ -34,7 +35,7 @@ const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
|
||||||
|
|
||||||
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
||||||
|
|
||||||
const { clearAlerts, assignError, setSuccess } = useRequestState();
|
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
||||||
|
|
||||||
const refreshingJobs = ref(false);
|
const refreshingJobs = ref(false);
|
||||||
const runningRepair = ref(false);
|
const runningRepair = ref(false);
|
||||||
|
|
@ -246,6 +247,15 @@ defineExpose({ load });
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="grid gap-4">
|
<section class="grid gap-4">
|
||||||
|
<RequestStateAlerts
|
||||||
|
:success-message="successMessage"
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:error-request-id="errorRequestId"
|
||||||
|
success-title="Repair operation complete"
|
||||||
|
error-title="Repair operation failed"
|
||||||
|
@dismiss-success="successMessage = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppCard class="space-y-3">
|
<AppCard class="space-y-3">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@ async function onStartRun(): Promise<void> {
|
||||||
pub.successMessage.value = null;
|
pub.successMessage.value = null;
|
||||||
pub.errorMessage.value = null;
|
pub.errorMessage.value = null;
|
||||||
pub.errorRequestId.value = null;
|
pub.errorRequestId.value = null;
|
||||||
|
try {
|
||||||
const result = await runStatus.startManualCheck();
|
const result = await runStatus.startManualCheck();
|
||||||
if (result.kind === "started") { pub.successMessage.value = `Run #${result.runId} started.`; return; }
|
if (result.kind === "started") { pub.successMessage.value = `Run #${result.runId} started.`; return; }
|
||||||
if (result.kind === "already_running") {
|
if (result.kind === "already_running") {
|
||||||
|
|
@ -343,6 +344,14 @@ async function onStartRun(): Promise<void> {
|
||||||
}
|
}
|
||||||
pub.errorMessage.value = result.message;
|
pub.errorMessage.value = result.message;
|
||||||
pub.errorRequestId.value = result.requestId;
|
pub.errorRequestId.value = result.requestId;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
pub.errorMessage.value = error.message;
|
||||||
|
pub.errorRequestId.value = error.requestId;
|
||||||
|
} else {
|
||||||
|
pub.errorMessage.value = "Unable to start manual run.";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue