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:
parent
3cdd14c345
commit
b13abb1118
4 changed files with 151 additions and 4 deletions
16
.env.example
16
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
|
|||
57
web/src/stores/settings/sync.ts
Normal file
57
web/src/stores/settings/sync.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue