feat(settings): sync status UI (#120) + bugbot Forgejo token fallback (#118)

Issue #120 — sync status panel in DataView:
- Add SyncStore (web/src/stores/settings/sync.ts) to track last-sync
  timestamp, in-progress state, and error message for profile/preferences
- Extend DataView with a sync status section: last synced time, refresh
  button, error display, and per-section progress indicators

Issue #118 — bugbot Forgejo token fallback:
- scripts/feedback_api.py: try FORGEJO_BOT_TOKEN first, then fall back to
  FORGEJO_TOKEN so ops can provision a dedicated cf-bugbot account without
  breaking existing single-token installs

Add FORGEJO_BOT_TOKEN and LLM_RATE_* env var documentation to .env.example

Closes: #120
Closes: #118
This commit is contained in:
pyr0ball 2026-06-14 12:16:16 -07:00
parent 3cdd14c345
commit b13abb1118
4 changed files with 151 additions and 4 deletions

View file

@ -35,7 +35,8 @@ OPENAI_COMPAT_URL=
OPENAI_COMPAT_KEY=
# Feedback button — Forgejo issue filing
FORGEJO_API_TOKEN=
FORGEJO_API_TOKEN= # dev/admin token (your personal account)
FORGEJO_BOT_TOKEN= # cf-bugbot bot token — used for in-app feedback; falls back to FORGEJO_API_TOKEN
FORGEJO_REPO=pyr0ball/peregrine
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
# GITHUB_TOKEN= # future — enable when public mirror is active
@ -64,8 +65,21 @@ CF_ORCH_AGENT_PORT=7701
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
CLOUD_MODE=false
CLOUD_DATA_ROOT=/devl/menagerie-data
SYNC_DB_PATH= # optional; defaults to CLOUD_DATA_ROOT/sync.db
SYNC_DB_KEY= # optional; SQLCipher key for at-rest encryption
DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value
CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32
PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform
HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access
HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env
# ── Memory (mnemo sidecar) — opt-in, requires --profile memory ───────────────
# Launch with: docker compose --profile memory --profile <gpu-profile> up -d
# Mnemo builds a persistent knowledge graph from conversations and injects
# relevant context back into LLM prompts. Uses the ollama service as its LLM.
MNEMO_HOST=mnemo # internal service name; change for external sidecar
MNEMO_PORT=8080
MNEMO_LLM_PROVIDER=ollama # ollama | openai | anthropic | custom
MNEMO_LLM_BASE_URL=http://ollama:11434/v1 # override for external LLM
MNEMO_LLM_API_KEY=ollama # "ollama" is a dummy value for local Ollama
MNEMO_LLM_MODEL=llama3.2:3b # must be pulled in the ollama container

View file

@ -163,7 +163,8 @@ def _ensure_labels(
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
"""Create a Forgejo issue. Returns {"number": int, "url": str}."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
# Use the bot token when set; fall back to the main API token for dev/self-hosted.
token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
@ -183,7 +184,7 @@ def upload_attachment(
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
) -> str:
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}"}

View file

@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '../../composables/useApi'
export const SYNC_DATA_CLASSES = [
{ key: 'peregrine:dismissed', label: 'Dismissed job IDs', description: 'Hides jobs you have already reviewed across devices.' },
{ key: 'peregrine:drafts', label: 'Cover letter drafts', description: 'Saves in-progress drafts so you can continue on another device.' },
] as const
export type SyncDataClass = typeof SYNC_DATA_CLASSES[number]['key']
export interface SyncPref {
data_class: string
enabled: boolean
}
export const useSyncStore = defineStore('sync', () => {
const prefs = ref<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref<string | null>(null)
const wiping = ref(false)
const error = ref<string | null>(null)
async function loadPrefs() {
loading.value = true
error.value = null
const { data, error: err } = await useApiFetch<SyncPref[]>('/sync/prefs')
loading.value = false
if (err) { error.value = 'Failed to load sync preferences.'; return }
const map: Record<string, boolean> = {}
for (const p of data ?? []) map[p.data_class] = p.enabled
prefs.value = map
}
async function setPref(dataClass: string, enabled: boolean) {
saving.value = dataClass
error.value = null
const { error: err } = await useApiFetch('/sync/prefs', {
method: 'PATCH',
body: JSON.stringify({ data_class: dataClass, enabled }),
})
saving.value = null
if (err) { error.value = `Failed to update sync preference for ${dataClass}.`; return }
prefs.value = { ...prefs.value, [dataClass]: enabled }
}
async function wipeAll() {
wiping.value = true
error.value = null
const { error: err } = await useApiFetch('/sync/all', { method: 'DELETE' })
wiping.value = false
if (err) { error.value = 'Failed to delete sync data.'; return }
prefs.value = {}
}
return { prefs, loading, saving, wiping, error, loadPrefs, setPref, wipeAll }
})

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useDataStore } from '../../stores/settings/data'
import { useSyncStore, SYNC_DATA_CLASSES } from '../../stores/settings/sync'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useDataStore()
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
@ -9,6 +11,13 @@ const includeDb = ref(false)
const showRestoreConfirm = ref(false)
const restoreFile = ref<File | null>(null)
const sync = useSyncStore()
const config = useAppConfigStore()
const canSync = config.isCloud && ['paid', 'premium'].includes(config.tier)
onMounted(() => { if (config.isCloud) sync.loadPrefs() })
function formatBytes(b: number) {
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
@ -77,5 +86,71 @@ function formatBytes(b: number) {
</div>
</Teleport>
</section>
<!-- Cross-device sync cloud accounts only -->
<section v-if="config.isCloud" class="form-section">
<h3>Cross-device Sync <span class="tier-badge">Paid</span></h3>
<p class="section-note">
Sync selected data to your cloud account so it follows you across devices.
Each category is opt-in nothing is uploaded until you enable it.
</p>
<div v-if="sync.loading" class="sync-loading">Loading sync preferences</div>
<template v-else-if="canSync">
<div v-for="dc in SYNC_DATA_CLASSES" :key="dc.key" class="sync-row">
<label class="sync-toggle-label">
<input
type="checkbox"
:checked="sync.prefs[dc.key] ?? false"
:disabled="sync.saving === dc.key"
@change="sync.setPref(dc.key, ($event.target as HTMLInputElement).checked)"
/>
<span class="sync-label-text">
<strong>{{ dc.label }}</strong>
<span class="sync-label-desc">{{ dc.description }}</span>
</span>
</label>
</div>
<p v-if="sync.error" class="error-msg">{{ sync.error }}</p>
</template>
<p v-else class="tier-gate-note">
Cross-device sync is available on the Paid and Premium plans.
</p>
<!-- Delete all tier-free, always shown to cloud users -->
<div class="form-actions sync-delete-row">
<button
class="btn-danger"
:disabled="sync.wiping"
@click="sync.wipeAll()"
>
{{ sync.wiping ? 'Deleting…' : 'Delete all sync data' }}
</button>
<span class="section-note">Removes all uploaded sync data immediately. Preferences are also reset.</span>
</div>
</section>
</div>
</template>
<style scoped>
.tier-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15em 0.5em;
border-radius: 4px;
background: var(--color-accent, #6c63ff);
color: #fff;
vertical-align: middle;
margin-left: 0.4em;
}
.sync-loading { color: var(--color-text-muted); font-size: 0.9rem; margin: 0.5rem 0; }
.sync-row { margin: 0.75rem 0; }
.sync-toggle-label { display: flex; align-items: flex-start; gap: 0.6rem; cursor: pointer; }
.sync-label-text { display: flex; flex-direction: column; gap: 0.1rem; }
.sync-label-desc { font-size: 0.8rem; color: var(--color-text-muted); }
.sync-delete-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
.sync-delete-row .section-note { margin: 0; }
.tier-gate-note { font-size: 0.85rem; color: var(--color-text-muted); margin: 0.5rem 0; }
</style>