CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
Step 3 of the Scribe -> Grimoire rename. - Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*. - hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR / SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`. - skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes arrive as grimoire-proc-<slug>; moment_actions names them the same way. - Migration 0124 rewrites stored skill names (rule_moments, rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing. - Settings' install commands and MCP-only defaults, the docs and the tests follow. - scripts/check_plugin.py drops the smoke event for a report-check hook that has no file in plugin/hooks. Kept on purpose: the secret-sealing label, and the names that let backups written as Scribe restore and be counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4428 lines
162 KiB
Vue
4428 lines
162 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted } from "vue";
|
||
import { useSettingsStore } from "@/stores/settings";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import { useToastStore } from "@/stores/toast";
|
||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||
import { usePlatformsStore } from "@/stores/platforms";
|
||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, apiErrorMessage } from "@/api/client";
|
||
import type { User } from "@/types/auth";
|
||
import PaginationBar from "@/components/PaginationBar.vue";
|
||
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
||
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
|
||
import { formatBytes } from "@/utils/bytes";
|
||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||
|
||
const store = useSettingsStore();
|
||
const authStore = useAuthStore();
|
||
const toastStore = useToastStore();
|
||
|
||
// ── Shared areas ────────────────────────────────────────────────────────
|
||
// The global vocabulary a project's Systems map onto. Admin-only to WRITE —
|
||
// a global list anyone can extend stops being a shared list — but every user
|
||
// reads it, which is why the catalog lives in a store rather than here.
|
||
const canonStore = useCanonicalSystemsStore();
|
||
const newAreaName = ref("");
|
||
const newAreaDescription = ref("");
|
||
const creatingArea = ref(false);
|
||
const editingAreaId = ref<number | null>(null);
|
||
const editAreaName = ref("");
|
||
const editAreaDescription = ref("");
|
||
const savingArea = ref(false);
|
||
|
||
async function createArea() {
|
||
const name = newAreaName.value.trim();
|
||
if (!name || creatingArea.value) return;
|
||
creatingArea.value = true;
|
||
try {
|
||
await canonStore.createEntry({
|
||
name,
|
||
description: newAreaDescription.value.trim() || undefined,
|
||
});
|
||
newAreaName.value = "";
|
||
newAreaDescription.value = "";
|
||
toastStore.show("Area added");
|
||
} catch (e) {
|
||
// A 409 means an area with the same match key already exists — say which,
|
||
// because "CI and Release" vs "CI & Release" looks like a different name.
|
||
toastStore.show(apiErrorMessage(e, "Failed to add area"), "error");
|
||
} finally {
|
||
creatingArea.value = false;
|
||
}
|
||
}
|
||
|
||
function startEditArea(id: number, name: string, description: string | null) {
|
||
editingAreaId.value = id;
|
||
editAreaName.value = name;
|
||
editAreaDescription.value = description ?? "";
|
||
}
|
||
|
||
async function saveArea() {
|
||
const id = editingAreaId.value;
|
||
const name = editAreaName.value.trim();
|
||
if (id == null || !name || savingArea.value) return;
|
||
savingArea.value = true;
|
||
try {
|
||
await canonStore.updateEntry(id, { name, description: editAreaDescription.value.trim() });
|
||
editingAreaId.value = null;
|
||
toastStore.show("Area updated");
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to update area"), "error");
|
||
} finally {
|
||
savingArea.value = false;
|
||
}
|
||
}
|
||
// ── Platforms ───────────────────────────────────────────────────────────
|
||
// The global list of what a project can be built on or ship as. Same split
|
||
// as the areas: admin-only to write, readable by everyone. Markers are the
|
||
// repo-relative globs that let a coverage refresh DETECT a platform; they
|
||
// are edited one per line, since a glob may itself contain a comma.
|
||
const platformsStore = usePlatformsStore();
|
||
const newPlatformName = ref("");
|
||
const newPlatformDescription = ref("");
|
||
const newPlatformMarkers = ref("");
|
||
const creatingPlatform = ref(false);
|
||
const editingPlatformId = ref<number | null>(null);
|
||
const editPlatformName = ref("");
|
||
const editPlatformDescription = ref("");
|
||
const editPlatformMarkers = ref("");
|
||
const savingPlatform = ref(false);
|
||
|
||
function markerLines(text: string): string[] {
|
||
return text.split("\n").map((m) => m.trim()).filter(Boolean);
|
||
}
|
||
|
||
async function createPlatform() {
|
||
const name = newPlatformName.value.trim();
|
||
if (!name || creatingPlatform.value) return;
|
||
creatingPlatform.value = true;
|
||
try {
|
||
await platformsStore.createEntry({
|
||
name,
|
||
description: newPlatformDescription.value.trim() || undefined,
|
||
markers: markerLines(newPlatformMarkers.value),
|
||
});
|
||
newPlatformName.value = "";
|
||
newPlatformDescription.value = "";
|
||
newPlatformMarkers.value = "";
|
||
toastStore.show("Platform added");
|
||
} catch (e) {
|
||
// A 409 names the existing platform the new name reduces to.
|
||
toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
|
||
} finally {
|
||
creatingPlatform.value = false;
|
||
}
|
||
}
|
||
|
||
function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
|
||
editingPlatformId.value = id;
|
||
editPlatformName.value = name;
|
||
editPlatformDescription.value = description ?? "";
|
||
editPlatformMarkers.value = markers.join("\n");
|
||
}
|
||
|
||
async function savePlatform() {
|
||
const id = editingPlatformId.value;
|
||
const name = editPlatformName.value.trim();
|
||
if (id == null || !name || savingPlatform.value) return;
|
||
savingPlatform.value = true;
|
||
try {
|
||
await platformsStore.updateEntry(id, {
|
||
name,
|
||
description: editPlatformDescription.value.trim(),
|
||
markers: markerLines(editPlatformMarkers.value),
|
||
});
|
||
editingPlatformId.value = null;
|
||
toastStore.show("Platform updated");
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
|
||
} finally {
|
||
savingPlatform.value = false;
|
||
}
|
||
}
|
||
|
||
const userTimezone = ref("");
|
||
const savingTimezone = ref(false);
|
||
const timezoneSaved = ref(false);
|
||
const trashRetentionDays = ref("90");
|
||
const savingRetention = ref(false);
|
||
const retentionSaved = ref(false);
|
||
// Knowledge auto-inject (per-user). Defaults mirror the backend
|
||
// (services/plugin_context: enabled, threshold 0.55, top-k 3).
|
||
const kbInjectEnabled = ref(true);
|
||
const kbInjectThreshold = ref("0.55");
|
||
const kbInjectTopK = ref("3");
|
||
const kbWritePathEnabled = ref(true);
|
||
// The write-path arm's OWN threshold, stricter than auto-inject's 0.55 above:
|
||
// code embeddings sit on a much higher similarity floor than prose, so 0.55
|
||
// lets unrelated code through. Shares top-k, not the threshold.
|
||
const kbWritePathThreshold = ref("0.68");
|
||
const kbRuleHintThreshold = ref("0.72");
|
||
const kbCheckpointThreshold = ref("0.80");
|
||
// The two ACT arms do not share a bar. A write-path query is a code payload; a
|
||
// pre-tool query is a shell command, often under a dozen words — less text,
|
||
// less signal, lower scores for the same relevance — so one shared bar makes
|
||
// them behave like different subsystems.
|
||
const kbToolRuleThreshold = ref("0.68");
|
||
// And the prompt boundary is a third query shape again — the operator's own
|
||
// prose rather than anything a tool produced.
|
||
const kbPromptRuleThreshold = ref("0.72");
|
||
// The reply arm: its bar is the bar at which a finished reply
|
||
// is HELD for one read, so it sits with the checkpoint, not with the hints.
|
||
const kbReplyRuleThreshold = ref("0.8");
|
||
// The BUDGETS, one per arm. Without one, the only control over a noisy surface
|
||
// is raising its bar, which discards the surface's best candidates along with
|
||
// its worst. A budget keeps the top of the ranking and drops the tail.
|
||
const kbWritePathTopK = ref("3");
|
||
const kbRuleHintTopK = ref("5");
|
||
const kbToolRuleTopK = ref("5");
|
||
const kbPromptRuleTopK = ref("3");
|
||
const kbReplyRuleTopK = ref("1");
|
||
// What has been changed about retrieval, newest first — the review surface for
|
||
// changes the model made on the operator's behalf.
|
||
const tuningEvents = ref<TuningEvent[]>([]);
|
||
const loadingTuning = ref(false);
|
||
// Near-duplicate report floors, one per record kind (services/dedup.py).
|
||
// Snippets are single-chunk, so their floor sits below the 0.90 write-time
|
||
// gate and catches what it lets through. Notes/tasks are scored at chunk
|
||
// grain — related families clear 0.90 easily — so their floor sits
|
||
// above the gate to keep the report pointed at genuinely-alike records.
|
||
const kbDupThresholdSnippet = ref("0.82");
|
||
const kbDupThresholdNote = ref("0.93");
|
||
const kbDupThresholdTask = ref("0.93");
|
||
// The create gate's bars. Defaults mirror services/dedup.py.
|
||
const kbGateThreshold = ref("0.9");
|
||
const kbGateThresholdSnippet = ref("0.96");
|
||
const kbGateThresholdLesson = ref("0.96");
|
||
const kbGateThresholdNoteCopy = ref("0.98");
|
||
const kbGateNoteOverlapFloor = ref("0.87");
|
||
// PLAN_MATCH_DEFAULT_THRESHOLD in services/dedup.py.
|
||
const kbPlanMatchThreshold = ref("0.80");
|
||
const savingKbInject = ref(false);
|
||
const kbInjectSaved = ref(false);
|
||
|
||
function detectTimezone() {
|
||
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
}
|
||
|
||
async function saveTimezone() {
|
||
savingTimezone.value = true;
|
||
timezoneSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', { user_timezone: userTimezone.value });
|
||
timezoneSaved.value = true;
|
||
setTimeout(() => (timezoneSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save timezone', 'error');
|
||
} finally {
|
||
savingTimezone.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveRetention() {
|
||
const n = Math.max(0, Math.floor(Number(trashRetentionDays.value) || 0));
|
||
trashRetentionDays.value = String(n);
|
||
savingRetention.value = true;
|
||
retentionSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', { trash_retention_days: String(n) });
|
||
retentionSaved.value = true;
|
||
setTimeout(() => (retentionSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save retention setting', 'error');
|
||
} finally {
|
||
savingRetention.value = false;
|
||
}
|
||
}
|
||
|
||
// One entry in the retrieval tuning trail. Mirrors
|
||
// RetrievalTuningEvent.to_dict() — `old_value` is null for the first change to
|
||
// a surface, which is a different event from moving off a value somebody chose
|
||
// and renders differently below.
|
||
interface TuningEvent {
|
||
id: number;
|
||
created_at: string | null;
|
||
surface: string;
|
||
dial: string;
|
||
old_value: number | null;
|
||
new_value: number;
|
||
actor: string;
|
||
reason: string;
|
||
}
|
||
|
||
// What a dial's number was measured against, and whether that space has moved
|
||
// Mirrors the `calibration` block `current_settings` returns per dial.
|
||
// `stale` is nullable on purpose: a dial moved before Grimoire recorded stamps is
|
||
// UNKNOWN rather than fine, and rendering the two the same would hide the case
|
||
// most likely to be wrong.
|
||
interface DialCalibration {
|
||
source: string;
|
||
embedding_model: string | null;
|
||
shape_version: number | null;
|
||
model_changed: boolean | null;
|
||
shape_changed: boolean | null;
|
||
stale: boolean | null;
|
||
}
|
||
|
||
interface SurfaceRow {
|
||
surface: string;
|
||
calibration: Record<string, DialCalibration>;
|
||
}
|
||
|
||
const surfaceRows = ref<SurfaceRow[]>([]);
|
||
|
||
// Flattened to one line per dial that needs looking at, because the operator's
|
||
// question is "which numbers do I no longer trust", not "tell me about each of
|
||
// six surfaces". Anything calibrated against the live model contributes
|
||
// nothing — the panel below renders only when this is non-empty, so a healthy
|
||
// install never sees it. A warning that is always on the screen is one nobody
|
||
// reads when it finally means something.
|
||
const staleDials = computed(() =>
|
||
surfaceRows.value.flatMap((row) =>
|
||
Object.entries(row.calibration ?? {})
|
||
.filter(([, cal]) => cal.stale !== false)
|
||
.map(([dial, cal]) => ({
|
||
key: `${row.surface}.${dial}`,
|
||
surface: row.surface,
|
||
dial,
|
||
why:
|
||
cal.source === "unstamped"
|
||
? "changed before Grimoire recorded what it was measured against"
|
||
: cal.model_changed && cal.shape_changed
|
||
? `measured under ${cal.embedding_model} at document shape ${cal.shape_version} — both have changed since`
|
||
: cal.model_changed
|
||
? `measured under ${cal.embedding_model}, which is no longer the embedding model`
|
||
: `measured at document shape ${cal.shape_version}, and documents are cut differently now`,
|
||
})),
|
||
),
|
||
);
|
||
|
||
async function loadSurfaces() {
|
||
try {
|
||
const res = await apiGet<{ surfaces: SurfaceRow[] }>("/api/retrieval/surfaces");
|
||
surfaceRows.value = res.surfaces ?? [];
|
||
} catch {
|
||
// Same reasoning as the history below: unreadable calibration is not worth
|
||
// a toast on page load, and the dials above still work.
|
||
surfaceRows.value = [];
|
||
}
|
||
}
|
||
|
||
async function loadTuningHistory() {
|
||
loadingTuning.value = true;
|
||
try {
|
||
const res = await apiGet<{ events: TuningEvent[] }>(
|
||
"/api/retrieval/tuning-history?limit=25",
|
||
);
|
||
tuningEvents.value = res.events ?? [];
|
||
} catch {
|
||
// A history that cannot be read is not worth a toast on page load — the
|
||
// panel says so itself, and the settings above are still usable.
|
||
tuningEvents.value = [];
|
||
} finally {
|
||
loadingTuning.value = false;
|
||
}
|
||
}
|
||
|
||
/** Who moved a dial. Three answers, not two.
|
||
*
|
||
* `release` means the shipped default itself changed between versions — no
|
||
* user turned anything. Folding that into the `human ? 'you' : 'Claude'`
|
||
* fallback labelled it "Claude", which tells the operator the session moved a
|
||
* floor it never touched. Misattribution is the one failure the `actor`
|
||
* column exists to prevent, so an unknown value says so rather than guessing.
|
||
*/
|
||
function actorLabel(actor: string): string {
|
||
if (actor === "human") return "you";
|
||
if (actor === "release") return "this release";
|
||
if (actor === "model") return "Claude";
|
||
return actor;
|
||
}
|
||
|
||
async function saveKbInject() {
|
||
// Every similarity bar, clamped the way the server's `bounded_float` clamps
|
||
// it: an unparseable value falls back to its default, never to 0, and `lo`
|
||
// is the floor a bar that BLOCKS must not go under (0.80 block, 0.70 overlap).
|
||
const asBar = (v: string, d: number, lo = 0) => Math.min(1, Math.max(lo, Number(v) || d));
|
||
const t = asBar(kbInjectThreshold.value, 0);
|
||
const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
|
||
// `|| default` not `|| 0`: an unparseable value here should fall back to the
|
||
// per-kind default, not to 0 — a 0 floor would report every record as a
|
||
// duplicate of every other one.
|
||
const dupSnip = asBar(kbDupThresholdSnippet.value, 0.82);
|
||
const dupNote = asBar(kbDupThresholdNote.value, 0.93);
|
||
const dupTask = asBar(kbDupThresholdTask.value, 0.93);
|
||
// The gate BLOCKS a write, so its bars have a floor the server enforces too:
|
||
// 0.80 for a block, 0.70 for the overlap list.
|
||
const gate = asBar(kbGateThreshold.value, 0.9, 0.8);
|
||
const gateSnip = asBar(kbGateThresholdSnippet.value, 0.96, 0.8);
|
||
const gateLesson = asBar(kbGateThresholdLesson.value, 0.96, 0.8);
|
||
const gateCopy = asBar(kbGateThresholdNoteCopy.value, 0.98, 0.8);
|
||
const gateOverlap = asBar(kbGateNoteOverlapFloor.value, 0.87, 0.7);
|
||
// Same `|| default` guard: a floor of 0 would hand back an existing plan
|
||
// for every new one, and no plan could be started without force.
|
||
const planT = asBar(kbPlanMatchThreshold.value, 0.8);
|
||
// Same `|| default` reasoning: falling back to 0 would surface every
|
||
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
||
const wpT = asBar(kbWritePathThreshold.value, 0.68);
|
||
// Same `|| default` reasoning again, and it bites harder here: a rule hint
|
||
// fires on every write, so a fallback of 0 would attach a standing rule to
|
||
// every edit in the session.
|
||
const rhT = asBar(kbRuleHintThreshold.value, 0.72);
|
||
// Same `|| default` guard, and the same reason: this arm fires before every
|
||
// Bash call, so a fallback of 0 would put a rule in front of every command.
|
||
const trT = asBar(kbToolRuleThreshold.value, 0.68);
|
||
const prT = asBar(kbPromptRuleThreshold.value, 0.72);
|
||
// The checkpoint bar, and the `|| default` guard matters most here of all:
|
||
// this is the only number that can STOP a call, so a fallback of 0 would
|
||
// hold the first command of every session behind whatever ranked first.
|
||
const cpT = asBar(kbCheckpointThreshold.value, 0.8);
|
||
// A stop bar like the checkpoint's: a fallback of 0 would hold every reply.
|
||
const ryT = asBar(kbReplyRuleThreshold.value, 0.8);
|
||
// The budgets, clamped the way the server clamps them: a whole number in
|
||
// [1, 10]. Never 0 — an arm turned off is turned off by its switch, and a
|
||
// budget of zero would run the search, log the retrieval and render nothing,
|
||
// which reads in the telemetry exactly like a bar nothing cleared.
|
||
const asK = (v: string, d: number) =>
|
||
Math.min(10, Math.max(1, Math.floor(Number(v) || d)));
|
||
const wpK = asK(kbWritePathTopK.value, 3);
|
||
const rhK = asK(kbRuleHintTopK.value, 5);
|
||
const trK = asK(kbToolRuleTopK.value, 5);
|
||
const prK = asK(kbPromptRuleTopK.value, 3);
|
||
const ryK = asK(kbReplyRuleTopK.value, 1);
|
||
kbWritePathTopK.value = String(wpK);
|
||
kbRuleHintTopK.value = String(rhK);
|
||
kbToolRuleTopK.value = String(trK);
|
||
kbPromptRuleTopK.value = String(prK);
|
||
kbReplyRuleTopK.value = String(ryK);
|
||
kbInjectThreshold.value = String(t);
|
||
kbInjectTopK.value = String(k);
|
||
kbDupThresholdSnippet.value = String(dupSnip);
|
||
kbDupThresholdNote.value = String(dupNote);
|
||
kbDupThresholdTask.value = String(dupTask);
|
||
kbGateThreshold.value = String(gate);
|
||
kbGateThresholdSnippet.value = String(gateSnip);
|
||
kbGateThresholdLesson.value = String(gateLesson);
|
||
kbGateThresholdNoteCopy.value = String(gateCopy);
|
||
kbGateNoteOverlapFloor.value = String(gateOverlap);
|
||
kbPlanMatchThreshold.value = String(planT);
|
||
kbWritePathThreshold.value = String(wpT);
|
||
kbRuleHintThreshold.value = String(rhT);
|
||
kbCheckpointThreshold.value = String(cpT);
|
||
kbToolRuleThreshold.value = String(trT);
|
||
kbPromptRuleThreshold.value = String(prT);
|
||
kbReplyRuleThreshold.value = String(ryT);
|
||
savingKbInject.value = true;
|
||
kbInjectSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', {
|
||
kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false',
|
||
kb_autoinject_threshold: String(t),
|
||
kb_autoinject_top_k: String(k),
|
||
// Its own switch AND its own threshold (shares only the ceiling) — see
|
||
// WRITEPATH_DEFAULT_THRESHOLD in services/plugin_context.py for the
|
||
// measurements that split them.
|
||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||
kb_writepath_threshold: String(wpT),
|
||
// A THIRD corpus with a third bar — see RULEHINT_DEFAULT_THRESHOLD
|
||
// in services/plugin_context.py for why rules cannot share the
|
||
// code threshold any more than code could share the prose one.
|
||
kb_rulehint_threshold: String(rhT),
|
||
// The one bar that stops a call rather than annotating it. Its own key,
|
||
// never derived from the two rule bars above: it answers a different
|
||
// question of the same scores — not "is this worth showing" but "is the
|
||
// corpus confident enough to be read first" — and a number that moves
|
||
// when another moves is a number nobody can reason about.
|
||
kb_checkpoint_threshold: String(cpT),
|
||
// A FOURTH and FIFTH bar, and they are separate keys on purpose: one
|
||
// number cannot serve arms whose queries are different shapes. Moving one must not move the others.
|
||
kb_toolrule_threshold: String(trT),
|
||
kb_promptrule_threshold: String(prT),
|
||
// The reply backstop's own bar: it HOLDS a reply, so like the
|
||
// checkpoint it is never derived from a hint bar.
|
||
kb_replyrule_threshold: String(ryT),
|
||
// The budgets. Every one of these keys is recognised by the server as a
|
||
// retrieval dial, so this save is recorded in the tuning history as a
|
||
// change the OPERATOR made — which is the one entry the model must not
|
||
// quietly tune back.
|
||
kb_writepath_top_k: String(wpK),
|
||
kb_rulehint_top_k: String(rhK),
|
||
kb_toolrule_top_k: String(trK),
|
||
kb_promptrule_top_k: String(prK),
|
||
kb_replyrule_top_k: String(ryK),
|
||
kb_duplicate_threshold_snippet: String(dupSnip),
|
||
kb_duplicate_threshold_note: String(dupNote),
|
||
kb_duplicate_threshold_task: String(dupTask),
|
||
kb_gate_threshold: String(gate),
|
||
kb_gate_threshold_snippet: String(gateSnip),
|
||
kb_gate_threshold_lesson: String(gateLesson),
|
||
kb_gate_threshold_note_copy: String(gateCopy),
|
||
kb_gate_note_overlap_floor: String(gateOverlap),
|
||
kb_plan_match_threshold: String(planT),
|
||
});
|
||
kbInjectSaved.value = true;
|
||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||
// The save just appended to the history it sits above, so re-read it —
|
||
// otherwise the panel shows a trail that is stale by exactly the change
|
||
// the operator is looking at it to confirm.
|
||
await loadTuningHistory();
|
||
await loadSurfaces();
|
||
} catch {
|
||
toastStore.show('Failed to save auto-inject settings', 'error');
|
||
} finally {
|
||
savingKbInject.value = false;
|
||
}
|
||
}
|
||
const newEmail = ref("");
|
||
const emailPassword = ref("");
|
||
const changingEmail = ref(false);
|
||
const currentPassword = ref("");
|
||
const newPassword = ref("");
|
||
const confirmNewPassword = ref("");
|
||
const changingPassword = ref(false);
|
||
const invalidatingSessions = ref(false);
|
||
const exporting = ref(false);
|
||
const restoring = ref(false);
|
||
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
|
||
// and the client's ordinary 30s default would cut them off mid-work. They are
|
||
// still bounded — every wait has a deadline, not necessarily a short one — and
|
||
// "no ceiling at all" is what leaves a restore that died server-side spinning forever.
|
||
const BULK_TRANSFER_TIMEOUT_MS = 10 * 60 * 1000;
|
||
|
||
function bulkDeadline(): AbortSignal {
|
||
return AbortSignal.timeout(BULK_TRANSFER_TIMEOUT_MS);
|
||
}
|
||
|
||
// ── What's running ──────────────────────────────────────────────────────
|
||
// Three states kept apart, because collapsing any two of them is the defect
|
||
// this readout exists to remove: `null` + no error = not asked yet (the Config
|
||
// tab has not been opened); a payload = answered, with each ABSENT field shown
|
||
// as "unknown"; `versionError` = the fetch itself failed, which is its own
|
||
// thing and must never render as a blank or as a plausible-looking value.
|
||
const versionInfo = ref<VersionPayload | null>(null);
|
||
const versionLoading = ref(false);
|
||
const versionError = ref("");
|
||
const commitCopied = ref(false);
|
||
|
||
async function loadVersionPanel() {
|
||
if (versionLoading.value) return;
|
||
versionLoading.value = true;
|
||
versionError.value = "";
|
||
try {
|
||
versionInfo.value = await fetchVersion();
|
||
} catch (e) {
|
||
versionInfo.value = null;
|
||
versionError.value = apiErrorMessage(e, "Could not reach the instance to ask what it is running.");
|
||
} finally {
|
||
versionLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function copyCommit() {
|
||
if (!versionInfo.value?.commit) return;
|
||
await copyToClipboard(versionInfo.value.commit);
|
||
commitCopied.value = true;
|
||
setTimeout(() => { commitCopied.value = false; }, 2000);
|
||
}
|
||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||
|
||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
|
||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||
|
||
function _loadTabContent(tab: string) {
|
||
if (authStore.isAdmin) {
|
||
if (tab === "users") loadUsersPanel();
|
||
else if (tab === "logs") loadLogsPanel();
|
||
else if (tab === "groups") loadGroupsPanel();
|
||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||
else if (tab === "platforms") platformsStore.fetchCatalog(true);
|
||
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||
}
|
||
if (tab === "apikeys") { fetchApiKeys(); }
|
||
}
|
||
|
||
watch(activeTab, (v) => {
|
||
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
||
_loadTabContent(v);
|
||
});
|
||
|
||
// MCP Access (API Keys are used as Bearer tokens for the in-app /mcp endpoint)
|
||
const apiKeys = ref<ApiKeyEntry[]>([]);
|
||
const newKeyName = ref('');
|
||
const newKeyScope = ref<'read' | 'write'>('write');
|
||
const newKeyValue = ref('');
|
||
const apiKeyCopied = ref(false);
|
||
const creatingApiKey = ref(false);
|
||
const revokeConfirmId = ref<number | null>(null);
|
||
|
||
const origin = window.location.origin;
|
||
const mcpUrl = computed(() => `${origin}/mcp`);
|
||
const mcpUrlCopied = ref(false);
|
||
const mcpClientTab = ref<'claude-code' | 'claude-desktop'>('claude-code');
|
||
const copiedSnippetKey = ref<string | null>(null);
|
||
|
||
// Configurable per-browser: the local name Claude uses for this server, and the
|
||
// scope of the `claude mcp add` call. Persisted to localStorage so the user's
|
||
// preferences carry across visits without needing a server-side setting.
|
||
const _MCP_NAME_KEY = 'mcp_server_name';
|
||
const _MCP_SCOPE_KEY = 'mcp_scope';
|
||
const _DEFAULT_MCP_NAME = 'grimoire';
|
||
const _isValidScope = (v: unknown): v is 'user' | 'project' | 'local' =>
|
||
v === 'user' || v === 'project' || v === 'local';
|
||
|
||
const mcpServerName = ref<string>(
|
||
localStorage.getItem(_MCP_NAME_KEY) || _DEFAULT_MCP_NAME,
|
||
);
|
||
const _storedScope = localStorage.getItem(_MCP_SCOPE_KEY);
|
||
const mcpScope = ref<'user' | 'project' | 'local'>(
|
||
_isValidScope(_storedScope) ? _storedScope : 'user',
|
||
);
|
||
|
||
watch(mcpServerName, (v) => {
|
||
const clean = (v || '').trim() || _DEFAULT_MCP_NAME;
|
||
localStorage.setItem(_MCP_NAME_KEY, clean);
|
||
});
|
||
watch(mcpScope, (v) => localStorage.setItem(_MCP_SCOPE_KEY, v));
|
||
|
||
const effectiveMcpName = computed(() => (mcpServerName.value || '').trim() || _DEFAULT_MCP_NAME);
|
||
const effectiveApiKey = computed(() => newKeyValue.value || '<your-token>');
|
||
|
||
const claudeCodeCommand = computed(() => {
|
||
// Note: `claude mcp add` takes the URL as a positional arg, not --url.
|
||
// Layout: claude mcp add [--transport ...] [--scope ...] <name> <url> [--header ...]
|
||
return `claude mcp add --transport http --scope ${mcpScope.value} ${effectiveMcpName.value} \\
|
||
${mcpUrl.value} \\
|
||
--header "Authorization: Bearer ${effectiveApiKey.value}"`;
|
||
});
|
||
|
||
// Plugin install — the recommended path. The Grimoire plugin bundles the MCP
|
||
// connection, a session-start hook that surfaces your rules, and the Grimoire
|
||
// process-skills. The marketplace is the Grimoire app's own git repo; persisted
|
||
// per-browser like the MCP fields above.
|
||
const _MKT_KEY = 'plugin_marketplace_url';
|
||
const pluginMarketplaceUrl = ref<string>(localStorage.getItem(_MKT_KEY) || '');
|
||
watch(pluginMarketplaceUrl, (v) => localStorage.setItem(_MKT_KEY, (v || '').trim()));
|
||
// Instance default, set by an admin (Admin tab) and loaded on mount. Used when
|
||
// the per-browser field is blank so the install command is copyable out of the box.
|
||
const serverMarketplaceUrl = ref('');
|
||
const adminMarketplaceUrl = ref('');
|
||
const savingMarketplaceUrl = ref(false);
|
||
const marketplaceUrlSaved = ref(false);
|
||
|
||
// DB maintenance (admin) — daily targeted VACUUM (ANALYZE).
|
||
interface DbMaintTableResult { table: string; ok: boolean; elapsed_ms: number; error: string | null }
|
||
interface DbMaintRun { started_at: string; elapsed_ms: number; tables: DbMaintTableResult[] }
|
||
const dbMaintEnabled = ref(true);
|
||
const dbMaintHour = ref(4);
|
||
const dbMaintLastRun = ref<DbMaintRun | null>(null);
|
||
const savingDbMaint = ref(false);
|
||
const dbMaintSaved = ref(false);
|
||
const runningDbMaint = ref(false);
|
||
interface DbTableHealth {
|
||
table: string; live: number; dead: number; dead_pct: number;
|
||
total_bytes: number; mod_since_analyze: number;
|
||
last_vacuum: string | null; last_analyze: string | null;
|
||
}
|
||
interface DbHealth { db_bytes: number; tables: DbTableHealth[] }
|
||
const dbHealth = ref<DbHealth | null>(null);
|
||
const loadingHealth = ref(false);
|
||
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind
|
||
|
||
const pluginInstallCommands = computed(() => {
|
||
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
||
|| serverMarketplaceUrl.value
|
||
|| '<your-grimoire-repo>.git';
|
||
return `/plugin marketplace add ${mkt}\n/plugin install grimoire@grimoire-plugin`;
|
||
});
|
||
|
||
const mcpConfigSnippet = computed(() => JSON.stringify({
|
||
mcpServers: {
|
||
[effectiveMcpName.value]: {
|
||
url: mcpUrl.value,
|
||
headers: {
|
||
Authorization: `Bearer ${effectiveApiKey.value}`,
|
||
},
|
||
},
|
||
},
|
||
}, null, 2));
|
||
|
||
async function copyMcpUrl() {
|
||
await copyToClipboard(mcpUrl.value);
|
||
mcpUrlCopied.value = true;
|
||
setTimeout(() => { mcpUrlCopied.value = false; }, 2000);
|
||
}
|
||
|
||
async function copyToClipboard(text: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch {
|
||
// Fallback for http (non-secure) contexts where clipboard API is unavailable
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
}
|
||
|
||
async function copySnippet(text: string, key: string) {
|
||
await copyToClipboard(text);
|
||
copiedSnippetKey.value = key;
|
||
setTimeout(() => {
|
||
if (copiedSnippetKey.value === key) copiedSnippetKey.value = null;
|
||
}, 2000);
|
||
}
|
||
|
||
async function fetchApiKeys() {
|
||
apiKeys.value = await listApiKeys();
|
||
}
|
||
|
||
async function createApiKey() {
|
||
if (!newKeyName.value) return;
|
||
creatingApiKey.value = true;
|
||
try {
|
||
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
|
||
newKeyValue.value = data.key;
|
||
newKeyName.value = '';
|
||
await fetchApiKeys();
|
||
} finally {
|
||
creatingApiKey.value = false;
|
||
}
|
||
}
|
||
|
||
async function revokeApiKey(id: number) {
|
||
await apiRevokeApiKey(id);
|
||
revokeConfirmId.value = null;
|
||
await fetchApiKeys();
|
||
}
|
||
|
||
async function copyApiKey() {
|
||
await copyToClipboard(newKeyValue.value);
|
||
apiKeyCopied.value = true;
|
||
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
|
||
}
|
||
|
||
// Groups management
|
||
const groups = ref<GroupEntry[]>([]);
|
||
const groupsLoading = ref(false);
|
||
const newGroupName = ref("");
|
||
const newGroupDesc = ref("");
|
||
const creatingGroup = ref(false);
|
||
const expandedGroupId = ref<number | null>(null);
|
||
const groupMembers = ref<Record<number, GroupMember[]>>({});
|
||
const groupMemberSearch = ref("");
|
||
const groupMemberResults = ref<UserSearchResult[]>([]);
|
||
let groupSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const groupMemberRole = ref("member");
|
||
|
||
async function loadGroupsPanel() {
|
||
groupsLoading.value = true;
|
||
try {
|
||
groups.value = await listGroups();
|
||
} finally {
|
||
groupsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function createNewGroup() {
|
||
if (!newGroupName.value.trim() || creatingGroup.value) return;
|
||
creatingGroup.value = true;
|
||
try {
|
||
await createGroup(newGroupName.value.trim(), newGroupDesc.value.trim() || undefined);
|
||
newGroupName.value = "";
|
||
newGroupDesc.value = "";
|
||
await loadGroupsPanel();
|
||
} finally {
|
||
creatingGroup.value = false;
|
||
}
|
||
}
|
||
|
||
async function deleteGroupConfirm(g: GroupEntry) {
|
||
if (!confirm(`Delete group "${g.name}"? This cannot be undone.`)) return;
|
||
await deleteGroup(g.id);
|
||
if (expandedGroupId.value === g.id) expandedGroupId.value = null;
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
async function toggleGroupExpand(g: GroupEntry) {
|
||
if (expandedGroupId.value === g.id) {
|
||
expandedGroupId.value = null;
|
||
return;
|
||
}
|
||
expandedGroupId.value = g.id;
|
||
groupMemberSearch.value = "";
|
||
groupMemberResults.value = [];
|
||
groupMembers.value[g.id] = await listGroupMembers(g.id);
|
||
}
|
||
|
||
function debounceGroupMemberSearch() {
|
||
if (groupSearchTimer) clearTimeout(groupSearchTimer);
|
||
groupSearchTimer = setTimeout(async () => {
|
||
if (groupMemberSearch.value.length < 2) { groupMemberResults.value = []; return; }
|
||
groupMemberResults.value = await searchUsers(groupMemberSearch.value);
|
||
}, 300);
|
||
}
|
||
|
||
async function addMemberToGroup(groupId: number, user: UserSearchResult) {
|
||
await addGroupMember(groupId, user.id, groupMemberRole.value);
|
||
groupMembers.value[groupId] = await listGroupMembers(groupId);
|
||
groupMemberSearch.value = "";
|
||
groupMemberResults.value = [];
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
async function removeMemberFromGroup(groupId: number, userId: number) {
|
||
await removeGroupMember(groupId, userId);
|
||
groupMembers.value[groupId] = await listGroupMembers(groupId);
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
|
||
// Notification preferences (email notifications only — push surface removed)
|
||
const notifyTaskReminders = ref(true);
|
||
const notifySecurityAlerts = ref(true);
|
||
const savingNotifications = ref(false);
|
||
const notificationsSaved = ref(false);
|
||
|
||
// SMTP settings (admin only)
|
||
const smtp = ref({
|
||
smtp_host: "",
|
||
smtp_port: "587",
|
||
smtp_username: "",
|
||
smtp_password: "",
|
||
smtp_from_address: "",
|
||
smtp_from_name: "Fabled Grimoire",
|
||
smtp_use_tls: "true",
|
||
});
|
||
const savingSmtp = ref(false);
|
||
const smtpSaved = ref(false);
|
||
const testRecipient = ref("");
|
||
const sendingTest = ref(false);
|
||
|
||
// Base URL setting (admin only)
|
||
const baseUrl = ref("");
|
||
const savingBaseUrl = ref(false);
|
||
const baseUrlSaved = ref(false);
|
||
|
||
// Git forge connections — the user's keyring: one read-only
|
||
// credential per forge host, used server-side for every forge read on
|
||
// projects this user owns. The token round-trips masked; the server treats
|
||
// the mask as "unchanged". Tokens are stored sealed under SECRET_KEY:
|
||
// `token_readable` false means this instance can no longer open one (the key
|
||
// changed since it was saved), so the row asks for it again.
|
||
interface ForgeConnectionEntry {
|
||
id: number; kind: string; base_url: string; host: string; token_readable: boolean;
|
||
}
|
||
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
|
||
const forgeKinds = ref<string[]>(["gitea", "github"]);
|
||
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
|
||
const connFormOpen = ref(false);
|
||
const unreadableConnections = computed(() => forgeConnections.value.filter((c) => !c.token_readable));
|
||
const savingConn = ref(false);
|
||
const testingConnId = ref(0);
|
||
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
|
||
// The webhook secret stays admin: the push endpoint is one URL per instance
|
||
// and authenticates deliveries, not users.
|
||
const forgeWebhookSecret = ref("");
|
||
const savingForgeWebhook = ref(false);
|
||
const forgeWebhookSaved = ref(false);
|
||
|
||
|
||
// Search test (SearXNG)
|
||
const searxngConfigured = ref(false);
|
||
const searxngUrl = ref("");
|
||
const searchQuery = ref("");
|
||
const searchResults = ref<{ url: string; title: string; snippet: string }[]>([]);
|
||
const searchLoading = ref(false);
|
||
const searchError = ref("");
|
||
|
||
|
||
onMounted(async () => {
|
||
await store.fetchSettings();
|
||
newEmail.value = authStore.user?.email ?? "";
|
||
|
||
// Load notification preferences from user settings
|
||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||
userTimezone.value = allSettings.user_timezone ?? "";
|
||
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
||
kbInjectEnabled.value = allSettings.kb_autoinject_enabled !== "false";
|
||
if (allSettings.kb_autoinject_threshold !== undefined) {
|
||
kbInjectThreshold.value = allSettings.kb_autoinject_threshold;
|
||
}
|
||
if (allSettings.kb_autoinject_top_k !== undefined) {
|
||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||
}
|
||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
||
if (allSettings.kb_checkpoint_threshold !== undefined) {
|
||
kbCheckpointThreshold.value = allSettings.kb_checkpoint_threshold;
|
||
}
|
||
if (allSettings.kb_rulehint_threshold !== undefined) {
|
||
kbRuleHintThreshold.value = allSettings.kb_rulehint_threshold;
|
||
}
|
||
if (allSettings.kb_toolrule_threshold !== undefined) {
|
||
kbToolRuleThreshold.value = allSettings.kb_toolrule_threshold;
|
||
}
|
||
if (allSettings.kb_promptrule_threshold !== undefined) {
|
||
kbPromptRuleThreshold.value = allSettings.kb_promptrule_threshold;
|
||
}
|
||
if (allSettings.kb_replyrule_threshold !== undefined) {
|
||
kbReplyRuleThreshold.value = allSettings.kb_replyrule_threshold;
|
||
}
|
||
if (allSettings.kb_writepath_threshold !== undefined) {
|
||
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
||
}
|
||
if (allSettings.kb_writepath_top_k !== undefined) {
|
||
kbWritePathTopK.value = allSettings.kb_writepath_top_k;
|
||
} else if (allSettings.kb_autoinject_top_k !== undefined) {
|
||
// The write path shared auto-inject's ceiling until it was given its own
|
||
// key, so an install that tuned the shared one must keep seeing that value
|
||
// here — the server falls back the same way.
|
||
kbWritePathTopK.value = allSettings.kb_autoinject_top_k;
|
||
}
|
||
if (allSettings.kb_rulehint_top_k !== undefined) {
|
||
kbRuleHintTopK.value = allSettings.kb_rulehint_top_k;
|
||
}
|
||
if (allSettings.kb_toolrule_top_k !== undefined) {
|
||
kbToolRuleTopK.value = allSettings.kb_toolrule_top_k;
|
||
}
|
||
if (allSettings.kb_promptrule_top_k !== undefined) {
|
||
kbPromptRuleTopK.value = allSettings.kb_promptrule_top_k;
|
||
}
|
||
if (allSettings.kb_replyrule_top_k !== undefined) {
|
||
kbReplyRuleTopK.value = allSettings.kb_replyrule_top_k;
|
||
}
|
||
await loadTuningHistory();
|
||
await loadSurfaces();
|
||
if (allSettings.kb_duplicate_threshold_snippet !== undefined) {
|
||
kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet;
|
||
}
|
||
if (allSettings.kb_duplicate_threshold_note !== undefined) {
|
||
kbDupThresholdNote.value = allSettings.kb_duplicate_threshold_note;
|
||
}
|
||
if (allSettings.kb_duplicate_threshold_task !== undefined) {
|
||
kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task;
|
||
}
|
||
for (const [key, target] of [
|
||
["kb_gate_threshold", kbGateThreshold],
|
||
["kb_gate_threshold_snippet", kbGateThresholdSnippet],
|
||
["kb_gate_threshold_lesson", kbGateThresholdLesson],
|
||
["kb_gate_threshold_note_copy", kbGateThresholdNoteCopy],
|
||
["kb_gate_note_overlap_floor", kbGateNoteOverlapFloor],
|
||
] as const) {
|
||
if (allSettings[key] !== undefined) target.value = allSettings[key];
|
||
}
|
||
if (allSettings.kb_plan_match_threshold !== undefined) {
|
||
kbPlanMatchThreshold.value = allSettings.kb_plan_match_threshold;
|
||
}
|
||
if (allSettings.notify_task_reminders !== undefined) {
|
||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||
}
|
||
if (allSettings.notify_security_alerts !== undefined) {
|
||
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
||
}
|
||
|
||
// Check SearXNG status
|
||
try {
|
||
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
||
searxngConfigured.value = sr.configured;
|
||
searxngUrl.value = sr.searxng_url;
|
||
} catch {
|
||
searxngConfigured.value = false;
|
||
}
|
||
|
||
// Plugin marketplace URL (instance default; readable by all users so the
|
||
// install command in MCP Access is copyable).
|
||
try {
|
||
const mk = await apiGet<{ marketplace_url: string }>("/api/plugin/marketplace-url");
|
||
serverMarketplaceUrl.value = mk.marketplace_url || "";
|
||
adminMarketplaceUrl.value = mk.marketplace_url || "";
|
||
if (!pluginMarketplaceUrl.value) pluginMarketplaceUrl.value = mk.marketplace_url || "";
|
||
} catch {
|
||
// not configured yet
|
||
}
|
||
|
||
// DB maintenance config (admin only — endpoint is admin-gated).
|
||
if (authStore.isAdmin) {
|
||
try {
|
||
const dm = await apiGet<{ enabled: boolean; hour: number; last_run: DbMaintRun | null }>("/api/admin/db-maintenance");
|
||
dbMaintEnabled.value = dm.enabled;
|
||
dbMaintHour.value = dm.hour;
|
||
dbMaintLastRun.value = dm.last_run;
|
||
} catch {
|
||
// leave defaults
|
||
}
|
||
await loadDbHealth();
|
||
}
|
||
|
||
// Load admin settings
|
||
if (authStore.isAdmin) {
|
||
try {
|
||
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
|
||
smtp.value = { ...smtp.value, ...smtpConfig };
|
||
} catch {
|
||
// SMTP not configured yet
|
||
}
|
||
try {
|
||
const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
|
||
baseUrl.value = urlConfig.base_url;
|
||
} catch {
|
||
// base URL not configured yet
|
||
}
|
||
try {
|
||
await loadForgeWebhook();
|
||
} catch {
|
||
// webhook secret not configured yet
|
||
}
|
||
}
|
||
try {
|
||
await loadForgeConnections();
|
||
} catch {
|
||
// no keyring yet — the ordinary state
|
||
}
|
||
_loadTabContent(activeTab.value);
|
||
});
|
||
|
||
async function loadForgeConnections() {
|
||
const res = await apiGet<{
|
||
connections: ForgeConnectionEntry[]; kinds: string[];
|
||
}>("/api/settings/forge-connections");
|
||
forgeConnections.value = res.connections;
|
||
if (res.kinds?.length) forgeKinds.value = res.kinds;
|
||
}
|
||
|
||
async function loadForgeWebhook() {
|
||
const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
|
||
forgeWebhookSecret.value = cfg.webhook_secret;
|
||
}
|
||
|
||
async function changeEmail() {
|
||
changingEmail.value = true;
|
||
try {
|
||
const body: Record<string, string> = { email: newEmail.value.trim() };
|
||
if (authStore.user?.has_password) {
|
||
body.password = emailPassword.value;
|
||
}
|
||
const updated = await apiPut<import("@/types/auth").User>("/api/auth/email", body);
|
||
authStore.user = updated;
|
||
emailPassword.value = "";
|
||
toastStore.show("Email updated successfully");
|
||
} catch (e: unknown) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to update email"), "error");
|
||
} finally {
|
||
changingEmail.value = false;
|
||
}
|
||
}
|
||
|
||
async function invalidateSessions() {
|
||
invalidatingSessions.value = true;
|
||
try {
|
||
await apiPost("/api/auth/invalidate-sessions", {});
|
||
toastStore.show("All other sessions have been invalidated");
|
||
} catch {
|
||
toastStore.show("Failed to invalidate sessions", "error");
|
||
} finally {
|
||
invalidatingSessions.value = false;
|
||
}
|
||
}
|
||
|
||
async function changePassword() {
|
||
if (newPassword.value !== confirmNewPassword.value) {
|
||
toastStore.show("New passwords do not match", "error");
|
||
return;
|
||
}
|
||
changingPassword.value = true;
|
||
try {
|
||
await apiPut("/api/auth/password", {
|
||
current_password: currentPassword.value,
|
||
new_password: newPassword.value,
|
||
});
|
||
toastStore.show("Password changed successfully");
|
||
currentPassword.value = "";
|
||
newPassword.value = "";
|
||
confirmNewPassword.value = "";
|
||
} catch (e: unknown) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
|
||
} finally {
|
||
changingPassword.value = false;
|
||
}
|
||
}
|
||
|
||
async function exportData(scope: "user" | "full") {
|
||
exporting.value = true;
|
||
try {
|
||
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
||
const res = await fetch(url, { signal: bulkDeadline() });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||
}
|
||
const data = await res.json();
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `grimoire-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toastStore.show("Backup downloaded");
|
||
} catch (e) {
|
||
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||
} finally {
|
||
exporting.value = false;
|
||
}
|
||
}
|
||
|
||
const exportingNotes = ref(false);
|
||
|
||
async function exportNotes(format: "markdown" | "json") {
|
||
exportingNotes.value = true;
|
||
try {
|
||
const res = await fetch(`/api/export?format=${format}`, { signal: bulkDeadline() });
|
||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||
const blob = await res.blob();
|
||
const ext = format === "json" ? "json" : "zip";
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `grimoire-${stamp}.${ext}`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toastStore.show("Export downloaded");
|
||
} catch (e) {
|
||
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||
} finally {
|
||
exportingNotes.value = false;
|
||
}
|
||
}
|
||
|
||
function triggerRestoreUpload() {
|
||
restoreFileInput.value?.click();
|
||
}
|
||
|
||
async function saveNotifications() {
|
||
savingNotifications.value = true;
|
||
notificationsSaved.value = false;
|
||
try {
|
||
await apiPut("/api/settings", {
|
||
notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
|
||
notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
|
||
});
|
||
notificationsSaved.value = true;
|
||
setTimeout(() => (notificationsSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show("Failed to save notification preferences", "error");
|
||
} finally {
|
||
savingNotifications.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveSmtp() {
|
||
savingSmtp.value = true;
|
||
smtpSaved.value = false;
|
||
try {
|
||
await apiPut("/api/admin/smtp", smtp.value);
|
||
smtpSaved.value = true;
|
||
setTimeout(() => (smtpSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show("Failed to save SMTP settings", "error");
|
||
} finally {
|
||
savingSmtp.value = false;
|
||
}
|
||
}
|
||
|
||
async function sendTestEmail() {
|
||
if (!testRecipient.value.trim()) {
|
||
toastStore.show("Enter a recipient email address", "error");
|
||
return;
|
||
}
|
||
sendingTest.value = true;
|
||
try {
|
||
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
|
||
toastStore.show("Test email sent successfully");
|
||
} catch (e: unknown) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
|
||
} finally {
|
||
sendingTest.value = false;
|
||
}
|
||
}
|
||
|
||
function editConnection(c: ForgeConnectionEntry | null) {
|
||
connTestResult.value = null;
|
||
connFormOpen.value = true;
|
||
connForm.value = c
|
||
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
|
||
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
|
||
}
|
||
|
||
async function saveConnection() {
|
||
savingConn.value = true;
|
||
try {
|
||
const { id, ...values } = connForm.value;
|
||
if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
|
||
else await apiPost("/api/settings/forge-connections", values);
|
||
connFormOpen.value = false;
|
||
await loadForgeConnections();
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
|
||
} finally {
|
||
savingConn.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeConnection(id: number) {
|
||
try {
|
||
await apiDelete(`/api/settings/forge-connections/${id}`);
|
||
connTestResult.value = null;
|
||
await loadForgeConnections();
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
|
||
}
|
||
}
|
||
|
||
async function testConnection(id: number) {
|
||
testingConnId.value = id;
|
||
connTestResult.value = null;
|
||
try {
|
||
const res = await apiPost<{ version: string; username: string }>(
|
||
`/api/settings/forge-connections/${id}/test`, {},
|
||
);
|
||
connTestResult.value = {
|
||
id, ok: true,
|
||
message: `Connected — ${res.version}, authenticated as ${res.username}`,
|
||
};
|
||
} catch (e) {
|
||
connTestResult.value = {
|
||
id, ok: false,
|
||
message: apiErrorMessage(e, "Connection test failed"),
|
||
};
|
||
} finally {
|
||
testingConnId.value = 0;
|
||
}
|
||
}
|
||
|
||
async function saveForgeWebhook() {
|
||
savingForgeWebhook.value = true;
|
||
try {
|
||
await apiPut("/api/admin/forge-webhook", {
|
||
webhook_secret: forgeWebhookSecret.value,
|
||
});
|
||
await loadForgeWebhook();
|
||
forgeWebhookSaved.value = true;
|
||
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
|
||
} finally {
|
||
savingForgeWebhook.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveBaseUrl() {
|
||
savingBaseUrl.value = true;
|
||
baseUrlSaved.value = false;
|
||
try {
|
||
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
|
||
baseUrlSaved.value = true;
|
||
setTimeout(() => (baseUrlSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show("Failed to save application URL", "error");
|
||
} finally {
|
||
savingBaseUrl.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveMarketplaceUrl() {
|
||
savingMarketplaceUrl.value = true;
|
||
marketplaceUrlSaved.value = false;
|
||
try {
|
||
const url = adminMarketplaceUrl.value.trim();
|
||
await apiPut("/api/plugin/marketplace-url", { marketplace_url: url });
|
||
serverMarketplaceUrl.value = url;
|
||
// Reflect the new instance default in the copyable field unless the user
|
||
// set their own per-browser override.
|
||
if (!localStorage.getItem(_MKT_KEY)) pluginMarketplaceUrl.value = url;
|
||
marketplaceUrlSaved.value = true;
|
||
setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
|
||
} finally {
|
||
savingMarketplaceUrl.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveDbMaintenance() {
|
||
savingDbMaint.value = true;
|
||
dbMaintSaved.value = false;
|
||
try {
|
||
await apiPut("/api/admin/db-maintenance", {
|
||
enabled: dbMaintEnabled.value,
|
||
hour: dbMaintHour.value,
|
||
});
|
||
dbMaintSaved.value = true;
|
||
setTimeout(() => (dbMaintSaved.value = false), 2000);
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
|
||
} finally {
|
||
savingDbMaint.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadDbHealth() {
|
||
loadingHealth.value = true;
|
||
try {
|
||
dbHealth.value = await apiGet<DbHealth>("/api/admin/db-maintenance/health");
|
||
} catch {
|
||
// leave previous value
|
||
} finally {
|
||
loadingHealth.value = false;
|
||
}
|
||
}
|
||
|
||
async function runDbMaintenanceNow() {
|
||
runningDbMaint.value = true;
|
||
try {
|
||
const summary = await apiPost<DbMaintRun>("/api/admin/db-maintenance/run", {});
|
||
dbMaintLastRun.value = summary;
|
||
const failed = summary.tables.filter((t) => !t.ok).length;
|
||
toastStore.show(
|
||
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
|
||
failed ? "error" : "success",
|
||
);
|
||
await loadDbHealth(); // reflect the dead-tuple drop
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
|
||
} finally {
|
||
runningDbMaint.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleRestoreFile(event: Event) {
|
||
const file = (event.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
restoring.value = true;
|
||
try {
|
||
// The scheduled backup's .tar goes up as it is; the JSON download is
|
||
// parsed first so a damaged file fails here, before the upload.
|
||
const isTar = file.name.toLowerCase().endsWith(".tar");
|
||
const res = await fetch("/api/admin/restore", {
|
||
method: "POST",
|
||
headers: { "Content-Type": isTar ? "application/x-tar" : "application/json" },
|
||
body: isTar ? file : JSON.stringify(JSON.parse(await file.text())),
|
||
signal: bulkDeadline(),
|
||
});
|
||
if (res.status === 413) {
|
||
throw new Error(
|
||
"the file is bigger than the web upload allows. Restore it with " +
|
||
"`python -m grimoire.restore <file>` inside the Grimoire container instead.",
|
||
);
|
||
}
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||
}
|
||
const result = await res.json();
|
||
toastStore.show(
|
||
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
|
||
);
|
||
} catch (e) {
|
||
toastStore.show("Restore failed: " + (e as Error).message, "error");
|
||
} finally {
|
||
restoring.value = false;
|
||
if (restoreFileInput.value) restoreFileInput.value.value = "";
|
||
}
|
||
}
|
||
|
||
async function testSearch() {
|
||
const q = searchQuery.value.trim();
|
||
if (!q) return;
|
||
searchLoading.value = true;
|
||
searchError.value = "";
|
||
searchResults.value = [];
|
||
try {
|
||
const data = await apiGet<{ configured: boolean; results: { url: string; title: string; snippet: string }[]; error?: string }>(
|
||
`/api/settings/search?q=${encodeURIComponent(q)}`
|
||
);
|
||
if (!data.configured) {
|
||
searchError.value = "SearXNG is not configured — set SEARXNG_URL in docker-compose.";
|
||
} else {
|
||
searchResults.value = data.results;
|
||
if (!data.results.length) searchError.value = "No results found.";
|
||
}
|
||
} catch {
|
||
searchError.value = "Search request failed.";
|
||
} finally {
|
||
searchLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function onSearchKeydown(e: KeyboardEvent) {
|
||
if (e.key === "Enter") testSearch();
|
||
}
|
||
|
||
function hostname(url: string): string {
|
||
try { return new URL(url).hostname; } catch { return url; }
|
||
}
|
||
|
||
// ── Users panel ──
|
||
|
||
interface Invitation {
|
||
id: number;
|
||
email: string;
|
||
created_at: string;
|
||
expires_at: string;
|
||
}
|
||
|
||
const users = ref<User[]>([]);
|
||
const registrationOpen = ref(false);
|
||
const usersLoading = ref(false);
|
||
const toggling = ref(false);
|
||
const confirmDeleteId = ref<number | null>(null);
|
||
const deleting = ref<number | null>(null);
|
||
const inviteEmail = ref("");
|
||
const sendingInvite = ref(false);
|
||
const invitations = ref<Invitation[]>([]);
|
||
const revokingId = ref<number | null>(null);
|
||
|
||
async function fetchUsers() {
|
||
try {
|
||
const data = await apiGet<{ users: User[] }>("/api/admin/users");
|
||
users.value = data.users;
|
||
} catch {
|
||
toastStore.show("Failed to load users", "error");
|
||
}
|
||
}
|
||
|
||
async function fetchRegistration() {
|
||
try {
|
||
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
|
||
registrationOpen.value = data.open;
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function fetchInvitations() {
|
||
try {
|
||
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
|
||
invitations.value = data.invitations;
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadUsersPanel() {
|
||
if (users.value.length > 0) return; // already loaded
|
||
usersLoading.value = true;
|
||
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
|
||
usersLoading.value = false;
|
||
}
|
||
|
||
// ── Logs panel ──
|
||
interface LogEntry {
|
||
id: number;
|
||
category: string;
|
||
user_id: number | null;
|
||
username: string | null;
|
||
action: string | null;
|
||
endpoint: string | null;
|
||
method: string | null;
|
||
status_code: number | null;
|
||
duration_ms: number | null;
|
||
ip_address: string | null;
|
||
details: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
interface LogStats {
|
||
audit: number;
|
||
usage: number;
|
||
error: number;
|
||
total: number;
|
||
}
|
||
|
||
const logs = ref<LogEntry[]>([]);
|
||
const logStats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
|
||
const logTotal = ref(0);
|
||
const logsLoading = ref(false);
|
||
const logsLoaded = ref(false);
|
||
const expandedLogId = ref<number | null>(null);
|
||
const logCategory = ref("");
|
||
const logSearch = ref("");
|
||
const logDateFrom = ref("");
|
||
const logDateTo = ref("");
|
||
const logLimit = 50;
|
||
const logOffset = ref(0);
|
||
let logSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
watch([logCategory, logDateFrom, logDateTo], () => {
|
||
logOffset.value = 0;
|
||
if (logsLoaded.value) fetchLogs();
|
||
});
|
||
watch(logSearch, () => {
|
||
if (logSearchTimeout) clearTimeout(logSearchTimeout);
|
||
logSearchTimeout = setTimeout(() => {
|
||
logOffset.value = 0;
|
||
if (logsLoaded.value) fetchLogs();
|
||
}, 300);
|
||
});
|
||
watch(logOffset, () => {
|
||
if (logsLoaded.value) fetchLogs();
|
||
});
|
||
|
||
async function fetchLogs() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (logCategory.value) params.set("category", logCategory.value);
|
||
if (logSearch.value) params.set("search", logSearch.value);
|
||
if (logDateFrom.value) params.set("date_from", logDateFrom.value);
|
||
if (logDateTo.value) params.set("date_to", logDateTo.value);
|
||
params.set("limit", String(logLimit));
|
||
params.set("offset", String(logOffset.value));
|
||
const data = await apiGet<{ logs: LogEntry[]; total: number }>(`/api/admin/logs?${params}`);
|
||
logs.value = data.logs;
|
||
logTotal.value = data.total;
|
||
} catch {
|
||
toastStore.show("Failed to load logs", "error");
|
||
}
|
||
}
|
||
|
||
async function fetchLogStats() {
|
||
try {
|
||
logStats.value = await apiGet<LogStats>("/api/admin/logs/stats");
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadLogsPanel() {
|
||
if (logsLoaded.value) return;
|
||
logsLoading.value = true;
|
||
await Promise.all([fetchLogs(), fetchLogStats()]);
|
||
logsLoaded.value = true;
|
||
logsLoading.value = false;
|
||
}
|
||
|
||
function toggleLogExpand(id: number) {
|
||
expandedLogId.value = expandedLogId.value === id ? null : id;
|
||
}
|
||
|
||
function formatLogDetails(details: string | null): string {
|
||
if (!details) return "";
|
||
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
|
||
}
|
||
|
||
function logDisplayLabel(entry: LogEntry): string {
|
||
if (entry.category === "audit" && entry.action) return entry.action;
|
||
if (entry.endpoint) return entry.endpoint;
|
||
return "—";
|
||
}
|
||
|
||
function clearLogFilters() {
|
||
logCategory.value = "";
|
||
logSearch.value = "";
|
||
logDateFrom.value = "";
|
||
logDateTo.value = "";
|
||
logOffset.value = 0;
|
||
}
|
||
|
||
async function sendInvite() {
|
||
const email = inviteEmail.value.trim().toLowerCase();
|
||
if (!email) return;
|
||
sendingInvite.value = true;
|
||
try {
|
||
await apiPost("/api/admin/invitations", { email });
|
||
toastStore.show(`Invitation sent to ${email}`);
|
||
inviteEmail.value = "";
|
||
await fetchInvitations();
|
||
} catch (e: unknown) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||
} finally {
|
||
sendingInvite.value = false;
|
||
}
|
||
}
|
||
|
||
async function revokeInvitation(id: number) {
|
||
revokingId.value = id;
|
||
try {
|
||
await apiDelete(`/api/admin/invitations/${id}`);
|
||
invitations.value = invitations.value.filter((inv) => inv.id !== id);
|
||
toastStore.show("Invitation revoked");
|
||
} catch {
|
||
toastStore.show("Failed to revoke invitation", "error");
|
||
} finally {
|
||
revokingId.value = null;
|
||
}
|
||
}
|
||
|
||
async function toggleRegistration() {
|
||
toggling.value = true;
|
||
try {
|
||
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
|
||
open: !registrationOpen.value,
|
||
});
|
||
registrationOpen.value = data.open;
|
||
toastStore.show(data.open ? "Registration opened" : "Registration closed");
|
||
} catch {
|
||
toastStore.show("Failed to update registration setting", "error");
|
||
} finally {
|
||
toggling.value = false;
|
||
}
|
||
}
|
||
|
||
function confirmDelete(userId: number) {
|
||
if (confirmDeleteId.value === userId) {
|
||
deleteUser(userId);
|
||
} else {
|
||
confirmDeleteId.value = userId;
|
||
}
|
||
}
|
||
function cancelDelete() { confirmDeleteId.value = null; }
|
||
|
||
async function deleteUser(userId: number) {
|
||
confirmDeleteId.value = null;
|
||
deleting.value = userId;
|
||
try {
|
||
await apiDelete(`/api/admin/users/${userId}`);
|
||
users.value = users.value.filter((u) => u.id !== userId);
|
||
toastStore.show("User deleted");
|
||
} catch (e: unknown) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||
} finally {
|
||
deleting.value = null;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="settings-root">
|
||
<aside class="settings-sidebar" role="navigation" aria-label="Settings navigation">
|
||
<div class="sidebar-group">
|
||
<div class="sidebar-group-label">User</div>
|
||
<button
|
||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'apikeys']"
|
||
:key="tab"
|
||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||
@click="activeTab = tab"
|
||
>
|
||
{{ tab === 'apikeys' ? 'MCP Access' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||
</button>
|
||
</div>
|
||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||
<div class="sidebar-group-label">Admin</div>
|
||
<button
|
||
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
|
||
:key="tab"
|
||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||
@click="activeTab = tab"
|
||
>
|
||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
<div class="settings-content">
|
||
|
||
<!-- ── General ── -->
|
||
<div v-show="activeTab === 'general'" class="settings-grid">
|
||
<!-- Timezone -->
|
||
<section class="settings-section full-width">
|
||
<h2>Timezone</h2>
|
||
<p class="section-desc">Used to schedule the daily journal prep and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
|
||
<div class="field">
|
||
<label for="user-timezone">Your timezone</label>
|
||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||
<input
|
||
id="user-timezone"
|
||
v-model="userTimezone"
|
||
type="text"
|
||
class="fs-input input"
|
||
placeholder="e.g. America/New_York"
|
||
/>
|
||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||
</div>
|
||
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveTimezone" :disabled="savingTimezone">
|
||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Trash retention -->
|
||
<section class="settings-section full-width">
|
||
<h2>Trash retention</h2>
|
||
<p class="section-desc">Deleted items move to <router-link to="/trash">Trash</router-link> and can be restored. They're permanently purged after this many days.</p>
|
||
<div class="field">
|
||
<label for="trash-retention">Retention period (days)</label>
|
||
<input
|
||
id="trash-retention"
|
||
v-model="trashRetentionDays"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveRetention" :disabled="savingRetention">
|
||
{{ savingRetention ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="retentionSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Knowledge auto-inject</h2>
|
||
<p class="section-desc">
|
||
When enabled, the Grimoire plugin quietly surfaces the titles of your most
|
||
relevant notes on each prompt — never their full text — so Claude can pull
|
||
one in with <code>get_note(id)</code> only when it helps. Titles only, each
|
||
note at most once per session, and nothing is shown unless it clears the
|
||
confidence bar below.
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="kbInjectEnabled" />
|
||
Surface relevant note titles each prompt
|
||
</label>
|
||
<p class="field-hint">Off = notes reach context only when Claude searches for them.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-inject-threshold">Confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-inject-threshold"
|
||
v-model="kbInjectThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.05"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-inject-topk">Max notes per prompt</label>
|
||
<input
|
||
id="kb-inject-topk"
|
||
v-model="kbInjectTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">Ceiling on titles surfaced at once (1–10).</p>
|
||
</div>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="kbWritePathEnabled" />
|
||
Also surface prior art when Claude writes code
|
||
</label>
|
||
<p class="field-hint">
|
||
Checks the file Claude is about to write or edit against your recorded
|
||
snippets — what's already kept at that path, and what resembles the code
|
||
being written — so a helper you already have is offered before it's
|
||
rewritten. When the file being edited is itself a recorded snippet's
|
||
location, the hint instead asks Claude to update or re-verify that
|
||
record as part of the edit — how records stay current without any forge
|
||
connection. Uses the ceiling above with its own threshold below, and never
|
||
blocks the edit. Off = prior art surfaces only on your own prompts.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-writepath-threshold">Prior-art confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-writepath-threshold"
|
||
v-model="kbWritePathThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Stricter than the prompt threshold above on purpose. Any two pieces of
|
||
code look somewhat alike — shared keywords, indentation, structure — so
|
||
resemblance scores start higher for code than for prose, and a bar tuned
|
||
for prompts flags unrelated code as prior art. Claude keeps this
|
||
one current from what the arm actually surfaced and refused; set it
|
||
yourself if you disagree with where it has landed. Snippets recorded at the
|
||
exact file are always shown regardless — those are prior art by
|
||
location, not by resemblance.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-writepath-topk">Max prior-art lines per edit</label>
|
||
<input
|
||
id="kb-writepath-topk"
|
||
v-model="kbWritePathTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">How many snippets and issues one edit may be offered (1–10). Reach for this rather than the threshold when the hint feels long: lowering it keeps the best matches and drops the tail, while raising the bar above throws away good matches along with weak ones.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-rulehint-threshold"
|
||
v-model="kbRuleHintThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The same hint can mention a standing rule whose trigger resembles the
|
||
code being written. <em>Every</em> rule is eligible — nothing is
|
||
preloaded any more, so this is the only way a rule reaches a write.
|
||
Stricter than the threshold above, because there are far fewer rules
|
||
than snippets: with a small set something always ranks first, so the
|
||
bar has to carry more of the judgement. If rules arrive unread, or one
|
||
you needed never showed up, that is Claude's to notice and correct
|
||
from the telemetry — and the budget below is usually the better lever.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-rulehint-topk">Max rules per edit</label>
|
||
<input
|
||
id="kb-rulehint-topk"
|
||
v-model="kbRuleHintTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">How many standing rules one edit may be shown (1–10).</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-checkpoint-threshold">Hold-before-acting threshold (0–1)</label>
|
||
<input
|
||
id="kb-checkpoint-threshold"
|
||
v-model="kbCheckpointThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Every hint above arrives <em>beside</em> the result of the action it
|
||
was about — useful to read afterwards, too late to change what the
|
||
action was. Above this bar a standing rule is instead put
|
||
<em>in front</em> of the command: Claude is asked to read the rule
|
||
first, and then runs the command anyway if the rule does not apply.
|
||
Set well above the thresholds above, because it costs a round trip
|
||
rather than a line. Only standing rules can hold a command, never
|
||
preferences; only a rule this session has not already read; and
|
||
never more than once per rule or five times per session, so a bar
|
||
set too low is a chatty session rather than a stuck one. Commands
|
||
only — file edits are never held.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-toolrule-threshold">Command confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-toolrule-threshold"
|
||
v-model="kbToolRuleThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The bar for a rule surfacing before a <em>command</em> runs, where the
|
||
query is the command text rather than code. Lower than the one above
|
||
on purpose: a shell command is short, so it scores lower for the same
|
||
relevance — at a shared bar this arm spoke on 2% of calls against the
|
||
write path's 37%.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-toolrule-topk">Max rules per command</label>
|
||
<input
|
||
id="kb-toolrule-topk"
|
||
v-model="kbToolRuleTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">How many standing rules one command may be shown (1–10). This is the busiest arm there is — it fires before every command — so its budget is the one most worth keeping small.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-promptrule-threshold">Prompt confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-promptrule-threshold"
|
||
v-model="kbPromptRuleThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The bar for a rule or preference surfacing against <em>what you just
|
||
said</em>, before anything is done. It is the only moment that reaches
|
||
a rule about how to answer rather than how to act, so it runs once a
|
||
turn rather than once a tool call. A third query shape — your prose,
|
||
not a command or a file — which is why it carries its own number.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-promptrule-topk">Max rules per prompt</label>
|
||
<input
|
||
id="kb-promptrule-topk"
|
||
v-model="kbPromptRuleTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">How many rules or preferences one message may be shown (1–10).</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-replyrule-threshold">Reply check threshold (0–1)</label>
|
||
<input
|
||
id="kb-replyrule-threshold"
|
||
v-model="kbReplyRuleThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The bar at which a <em>finished reply</em> is held for one read: the
|
||
reply is searched against every rule's trigger, and a rule this
|
||
session has not opened that scores at or above this bar holds the
|
||
reply once, naming the rule. It is the backstop for whatever the
|
||
earlier checks missed, so it is a stop bar like the one above, not a
|
||
hint bar — keep it high.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-replyrule-topk">Rules searched per reply</label>
|
||
<input
|
||
id="kb-replyrule-topk"
|
||
v-model="kbReplyRuleTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">How many of the best-scoring rules a reply is checked against (1–10). Only the best can hold the reply; the rest are counted, which is what tells you how close the others came.</p>
|
||
</div>
|
||
<!-- THE REVIEW SURFACE. These numbers are maintained by the
|
||
model that uses them: it reads which records each bar refused and
|
||
moves the dial with the argument attached. This panel is the other
|
||
half of that bargain — a change made on your behalf is one you can
|
||
read, disagree with, and set back by hand above. -->
|
||
<!-- STALENESS. A bar is a cosine similarity, which only means
|
||
something inside one embedding model's geometry over documents cut
|
||
one particular way. Change either and every number above keeps
|
||
applying while describing nothing. Shown ONLY when something has
|
||
actually moved, so that seeing it at all is the signal. -->
|
||
<div v-if="staleDials.length" class="calibration-warning">
|
||
<h4 class="calibration-warning-title">
|
||
{{ staleDials.length }}
|
||
{{ staleDials.length === 1 ? 'value was' : 'values were' }}
|
||
measured against something that has changed
|
||
</h4>
|
||
<p class="field-hint">
|
||
These bars are similarity scores, and a similarity score only means
|
||
something inside the model that produced it. They still apply — they
|
||
just no longer measure what they were set to measure. Nothing is
|
||
adjusted automatically; ask Claude to migrate them and it will carry
|
||
each one across at the same selectivity, then check the result.
|
||
</p>
|
||
<ul class="calibration-list">
|
||
<li v-for="d in staleDials" :key="d.key">
|
||
<span class="tuning-surface">{{ d.surface }}</span>
|
||
<span class="tuning-dial">{{ d.dial }}</span>
|
||
<span class="calibration-why">{{ d.why }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div class="tuning-history">
|
||
<h4 class="tuning-history-title">What has been tuned</h4>
|
||
<p class="field-hint">
|
||
Claude adjusts the bars and budgets above from what each arm actually
|
||
surfaced and refused, and has to state a reason to change one. Nothing
|
||
here needs your attention as a matter of course — it is here so that
|
||
when a surface behaves oddly, why it is set the way it is can be read
|
||
rather than guessed at. Anything you change yourself is recorded the
|
||
same way, and is the one entry Claude will not quietly move back.
|
||
</p>
|
||
<p v-if="loadingTuning" class="field-hint">Loading…</p>
|
||
<p v-else-if="!tuningEvents.length" class="field-hint">
|
||
Nothing has been changed yet — every surface is on the value Grimoire
|
||
shipped. Those are starting points measured against one corpus with
|
||
one embedding model, not answers, so expect this to fill.
|
||
</p>
|
||
<ul v-else class="tuning-list">
|
||
<li v-for="ev in tuningEvents" :key="ev.id" class="tuning-item">
|
||
<div class="tuning-item-head">
|
||
<span class="tuning-surface">{{ ev.surface }}</span>
|
||
<span class="tuning-dial">{{ ev.dial }}</span>
|
||
<span class="tuning-move">
|
||
<template v-if="ev.old_value !== null">{{ ev.old_value }} →</template>
|
||
<template v-else>set to</template>
|
||
{{ ev.new_value }}
|
||
</span>
|
||
<span
|
||
class="tuning-actor"
|
||
:class="{ 'is-human': ev.actor === 'human', 'is-release': ev.actor === 'release' }"
|
||
>
|
||
{{ actorLabel(ev.actor) }}
|
||
</span>
|
||
<span v-if="ev.created_at" class="tuning-when">{{ fmtDate(ev.created_at) }}</span>
|
||
</div>
|
||
<p class="tuning-reason">{{ ev.reason }}</p>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- A design system belongs to a PROJECT, and the picker for it lives on
|
||
the project, not here. -->
|
||
|
||
<div class="field">
|
||
<label for="kb-duplicate-threshold-snippet">Near-duplicate report threshold — snippets</label>
|
||
<input
|
||
id="kb-duplicate-threshold-snippet"
|
||
v-model="kbDupThresholdSnippet"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
How alike two snippets must be before the Snippets page suggests merging
|
||
them. Lower = more suggestions, more false pairs. Looser than the 0.90
|
||
used to block a duplicate at creation, because this only proposes a merge
|
||
you review — it never acts on its own.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-duplicate-threshold-note">Near-duplicate report threshold — notes</label>
|
||
<input
|
||
id="kb-duplicate-threshold-note"
|
||
v-model="kbDupThresholdNote"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The floor for the Knowledge page's note report. Notes are compared
|
||
section by section, so related records — a run of dev-logs, notes on
|
||
one topic — score high without being duplicates. Stricter than the
|
||
snippet floor on purpose; lower it to browse related families rather
|
||
than hunt true duplicates.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-duplicate-threshold-task">Near-duplicate report threshold — tasks</label>
|
||
<input
|
||
id="kb-duplicate-threshold-task"
|
||
v-model="kbDupThresholdTask"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Same as the note floor, for the task report. Step tasks from different
|
||
milestones ("Verify on CI") legitimately resemble each other, so this
|
||
stays strict to keep the report pointed at work opened twice.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-gate-threshold">Create gate — general block threshold</label>
|
||
<input
|
||
id="kb-gate-threshold"
|
||
v-model="kbGateThreshold"
|
||
type="number"
|
||
min="0.8"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
When a new record is at least this alike to an existing one of the same kind, the create is refused and pointed at the existing record to update. Applies to kinds without their own bar below (processes, and anything new). Higher = fewer refusals, more duplicates let through. Cannot go below 0.80: the gate blocks a write, and a low bar refuses everything on a shared topic.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-gate-threshold-snippet">Create gate — snippets</label>
|
||
<input
|
||
id="kb-gate-threshold-snippet"
|
||
v-model="kbGateThresholdSnippet"
|
||
type="number"
|
||
min="0.8"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The semantic backstop for snippets. Code and location are checked first and exactly, so this only catches a reworded copy; it sits above the band where deliberate siblings (a button and its outline variant) land.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-gate-threshold-lesson">Create gate — lessons</label>
|
||
<input
|
||
id="kb-gate-threshold-lesson"
|
||
v-model="kbGateThresholdLesson"
|
||
type="number"
|
||
min="0.8"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Lessons about one area read alike without being the same lesson, so this is high. A refusal loses a real lesson outright; a miss leaves two you can merge.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-gate-threshold-note-copy">Create gate — notes and tasks (copy)</label>
|
||
<input
|
||
id="kb-gate-threshold-note-copy"
|
||
v-model="kbGateThresholdNoteCopy"
|
||
type="number"
|
||
min="0.8"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Notes and tasks are refused only as a near-exact copy. Consecutive dev-logs and parts of one design score 0.90–0.98 without being duplicates, so a lower bar refuses the next one.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-gate-note-overlap-floor">Create gate — notes and tasks (listed overlaps)</label>
|
||
<input
|
||
id="kb-gate-note-overlap-floor"
|
||
v-model="kbGateNoteOverlapFloor"
|
||
type="number"
|
||
min="0.7"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Below the copy bar, matches above this are listed on the create reply (up to three) for the session to judge, and the record is still created. Lower = more listed, more of them noise. Never above the copy bar.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-plan-match-threshold">Existing-plan match threshold</label>
|
||
<input
|
||
id="kb-plan-match-threshold"
|
||
v-model="kbPlanMatchThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
How alike a new plan must be to an active milestone in the same project
|
||
before an agent is handed that milestone instead of creating a second
|
||
one. A plan with the same title always matches. Lower it if sessions
|
||
still open parallel plans for work that already has one; raise it if
|
||
they are sent to plans that are only related.
|
||
</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
|
||
{{ savingKbInject ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Moments</h2>
|
||
<p class="section-desc">
|
||
A rule mounted on a moment arrives whenever that moment happens, whatever the work
|
||
looks like — which is how a rule about <em>when</em> something is done reaches a session
|
||
that never mentions it. Mount rules from the rule editor; here is what each moment
|
||
means, which actions reach it on this install, and what it has delivered.
|
||
</p>
|
||
<MomentsSettings />
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Reply shapes</h2>
|
||
<p class="section-desc">
|
||
The default shape of each kind of reply, delivered to a session when it is due.
|
||
Adjust one with a preference; it arrives beside the shape and wins.
|
||
</p>
|
||
<ReplyShapesSettings />
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Account ── -->
|
||
<div v-show="activeTab === 'account'" class="settings-grid">
|
||
|
||
<!-- SSO accounts: no local credential management -->
|
||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||
<h2>Account</h2>
|
||
<p class="section-desc">
|
||
Your account is managed by an external identity provider.
|
||
Email and password changes are made through your provider, not here.
|
||
</p>
|
||
</section>
|
||
|
||
<template v-if="authStore.user?.has_password">
|
||
<section class="settings-section">
|
||
<h2>Email Address</h2>
|
||
<p class="section-desc">Used for password resets and notifications.</p>
|
||
<div class="field">
|
||
<label for="new-email">Email</label>
|
||
<input
|
||
id="new-email"
|
||
v-model="newEmail"
|
||
type="email"
|
||
placeholder="you@example.com"
|
||
class="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label for="email-password">Current Password</label>
|
||
<input
|
||
id="email-password"
|
||
v-model="emailPassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
class="fs-input input"
|
||
/>
|
||
<p class="field-hint">Required to confirm the change.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button
|
||
class="btn-primary"
|
||
@click="changeEmail"
|
||
:disabled="changingEmail || !emailPassword"
|
||
>
|
||
{{ changingEmail ? "Saving..." : "Save Email" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section">
|
||
<h2>Change Password</h2>
|
||
<div class="field">
|
||
<label for="current-password">Current Password</label>
|
||
<input
|
||
id="current-password"
|
||
v-model="currentPassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
class="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label for="new-password">New Password</label>
|
||
<input
|
||
id="new-password"
|
||
v-model="newPassword"
|
||
type="password"
|
||
autocomplete="new-password"
|
||
class="fs-input input"
|
||
/>
|
||
<p class="field-hint">Must be at least 8 characters</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="confirm-new-password">Confirm New Password</label>
|
||
<input
|
||
id="confirm-new-password"
|
||
v-model="confirmNewPassword"
|
||
type="password"
|
||
autocomplete="new-password"
|
||
class="fs-input input"
|
||
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
||
/>
|
||
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
||
Passwords do not match
|
||
</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button
|
||
class="btn-primary"
|
||
@click="changePassword"
|
||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||
>
|
||
{{ changingPassword ? "Changing..." : "Change Password" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<section class="settings-section">
|
||
<h2>Active Sessions</h2>
|
||
<p class="section-desc">
|
||
Sign out all other devices and sessions. Use this after a password change
|
||
to ensure stale sessions are revoked.
|
||
</p>
|
||
<div class="actions">
|
||
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
|
||
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Notifications ── -->
|
||
<div v-show="activeTab === 'notifications'" class="settings-grid">
|
||
|
||
<section class="settings-section">
|
||
<h2>Email Notifications</h2>
|
||
<p class="section-desc">
|
||
Email notifications when SMTP is configured by an admin.
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="notifyTaskReminders" />
|
||
Task due date reminders
|
||
</label>
|
||
<p class="field-hint">Daily email for tasks due or overdue.</p>
|
||
</div>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="notifySecurityAlerts" />
|
||
Security alerts
|
||
</label>
|
||
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveNotifications" :disabled="savingNotifications">
|
||
{{ savingNotifications ? "Saving..." : "Save" }}
|
||
</button>
|
||
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Integrations ── -->
|
||
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Web Search (SearXNG)</h2>
|
||
<template v-if="searxngConfigured">
|
||
<p class="section-desc">
|
||
Connected to <code class="url-chip">{{ searxngUrl }}</code>.
|
||
Test a query below to verify results and rate limiting.
|
||
</p>
|
||
<div class="search-row">
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
class="fs-input input"
|
||
placeholder="Enter a search query..."
|
||
@keydown="onSearchKeydown"
|
||
/>
|
||
<button class="btn-primary" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||
{{ searchLoading ? "Searching..." : "Search" }}
|
||
</button>
|
||
</div>
|
||
<p v-if="searchError" class="search-error">{{ searchError }}</p>
|
||
<ul v-if="searchResults.length" class="search-results">
|
||
<li v-for="(r, i) in searchResults" :key="i" class="search-result">
|
||
<div class="result-header">
|
||
<a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
|
||
<span class="result-host">{{ hostname(r.url) }}</span>
|
||
</div>
|
||
<p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
<template v-else>
|
||
<p class="section-desc not-configured">
|
||
Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
|
||
</p>
|
||
</template>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Git Forges</h2>
|
||
<p class="section-desc">
|
||
Read-only connections to your git forges, one per host. Projects you
|
||
own use them server-side — resolved by repo host — to fetch and
|
||
drift-check recorded snippets and measure pattern coverage. A
|
||
read-scope token is enough. Gitea: an access token with read scope
|
||
on repositories. GitHub: a fine-grained PAT with Contents:
|
||
Read-only, base URL <code>https://github.com</code> (or your GitHub
|
||
Enterprise URL).
|
||
</p>
|
||
<p v-if="unreadableConnections.length" class="calibration-warning" role="alert">
|
||
{{ unreadableConnections.length === 1 ? "One connection's token" : `${unreadableConnections.length} connections' tokens` }}
|
||
can't be read on this instance: <code>SECRET_KEY</code> has changed since
|
||
{{ unreadableConnections.length === 1 ? "it was" : "they were" }} saved. Edit
|
||
{{ unreadableConnections.map((c) => c.host).join(", ") }} and enter the token again.
|
||
Until then, forge reads for that host don't run.
|
||
</p>
|
||
<table v-if="forgeConnections.length" class="api-keys-table">
|
||
<thead>
|
||
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in forgeConnections" :key="c.id">
|
||
<td>
|
||
{{ c.host }}
|
||
<span v-if="!c.token_readable" class="conn-token-lost">token needs re-entering</span>
|
||
</td>
|
||
<td>{{ c.kind }}</td>
|
||
<td><code>{{ c.base_url }}</code></td>
|
||
<td>
|
||
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
|
||
{{ testingConnId === c.id ? "Testing…" : "Test" }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
|
||
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="section-desc not-configured">No forge connections yet.</p>
|
||
<p
|
||
v-if="connTestResult"
|
||
:class="connTestResult.ok ? 'text-success' : 'text-error'"
|
||
>
|
||
{{ connTestResult.message }}
|
||
</p>
|
||
<div v-if="connFormOpen" class="smtp-grid">
|
||
<div class="field">
|
||
<label for="conn-kind">Forge</label>
|
||
<select id="conn-kind" v-model="connForm.kind" class="fs-input input">
|
||
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="conn-base-url">Base URL</label>
|
||
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="conn-token">API Token (read scope)</label>
|
||
<input id="conn-token" v-model="connForm.token" type="password" class="fs-input input" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<template v-if="connFormOpen">
|
||
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
|
||
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
|
||
</button>
|
||
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
|
||
</template>
|
||
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Data ── -->
|
||
<div v-show="activeTab === 'data'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Export</h2>
|
||
<p class="section-desc">Download your notes and tasks in portable formats.</p>
|
||
<div class="data-actions">
|
||
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
|
||
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
|
||
</button>
|
||
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
|
||
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
|
||
</button>
|
||
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
|
||
{{ exporting ? "Exporting..." : "Export My Data" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<template v-if="authStore.isAdmin">
|
||
<section class="settings-section full-width">
|
||
<h2>Backup & Restore</h2>
|
||
<p class="section-desc">Full application backup includes all users and their data.</p>
|
||
<div class="data-actions">
|
||
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
||
{{ exporting ? "Exporting..." : "Full Backup" }}
|
||
</button>
|
||
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
|
||
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
||
</button>
|
||
<input
|
||
ref="restoreFileInput"
|
||
type="file"
|
||
accept=".json,.tar"
|
||
class="hidden-file-input"
|
||
@change="handleRestoreFile"
|
||
/>
|
||
</div>
|
||
</section>
|
||
<section class="settings-section full-width">
|
||
<h2>Scheduled backups</h2>
|
||
<BackupScheduleSettings />
|
||
</section>
|
||
</template>
|
||
|
||
</div>
|
||
|
||
|
||
<!-- ── MCP Access ── -->
|
||
<div v-show="activeTab === 'apikeys'" class="settings-grid">
|
||
<!-- Endpoint URL -->
|
||
<section class="settings-section full-width">
|
||
<h2>MCP Access</h2>
|
||
<p class="settings-description">
|
||
Connect Claude (Code or Desktop) to this Grimoire instance. Claude reads and writes your notes,
|
||
tasks, projects, events, and people via the built-in MCP endpoint below.
|
||
</p>
|
||
|
||
<div class="mcp-url-block">
|
||
<label class="mcp-url-label">MCP endpoint</label>
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ mcpUrl }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copyMcpUrl">
|
||
{{ mcpUrlCopied ? 'Copied' : 'Copy URL' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tokens -->
|
||
<section class="settings-section full-width">
|
||
<h2>Personal access tokens</h2>
|
||
<p class="settings-description">
|
||
Tokens are sent as <code>Authorization: Bearer …</code> on every MCP request and resolve to
|
||
your user account. Write-scoped tokens can create and update content; read-only tokens can
|
||
only query. A token is shown <strong>once</strong> at creation — keep it safe.
|
||
</p>
|
||
|
||
<!-- Create form -->
|
||
<div class="api-key-create-form">
|
||
<input v-model="newKeyName" placeholder="Token name (e.g. claude-laptop)" class="settings-input" />
|
||
<div class="api-key-scope-select">
|
||
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
|
||
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
|
||
</div>
|
||
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
|
||
{{ creatingApiKey ? 'Generating…' : 'Generate token' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- One-time token reveal -->
|
||
<div v-if="newKeyValue" class="api-key-reveal">
|
||
<p><strong>Copy this token now — it will not be shown again.</strong></p>
|
||
<div class="api-key-value-row">
|
||
<code class="api-key-value">{{ newKeyValue }}</code>
|
||
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
|
||
</div>
|
||
<p class="mcp-hint" style="margin-top:0.5rem;">
|
||
The snippets in <strong>Connect Claude</strong> below are pre-filled with this token until you click Done.
|
||
</p>
|
||
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
|
||
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tokens table -->
|
||
<table v-if="apiKeys.length > 0" class="api-keys-table">
|
||
<thead>
|
||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="key in apiKeys" :key="key.id">
|
||
<td>{{ key.name }}</td>
|
||
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
|
||
<td><code>{{ key.key_prefix }}…</code></td>
|
||
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
|
||
<td>
|
||
<span v-if="revokeConfirmId !== key.id">
|
||
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
|
||
</span>
|
||
<span v-else>
|
||
Sure?
|
||
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
|
||
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No tokens yet.</p>
|
||
</section>
|
||
|
||
<!-- Connect Claude -->
|
||
<section class="settings-section full-width">
|
||
<h2>Connect Claude</h2>
|
||
<p class="settings-description">
|
||
Run the appropriate snippet for your client. Generate a token above first to pre-fill the
|
||
token value, or copy the snippet now and paste your own where it says <code><your-token></code>.
|
||
</p>
|
||
|
||
<div class="mcp-client-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'claude-code'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-code' }]"
|
||
@click="mcpClientTab = 'claude-code'"
|
||
>Claude Code</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'claude-desktop'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-desktop' }]"
|
||
@click="mcpClientTab = 'claude-desktop'"
|
||
>Claude Desktop</button>
|
||
</div>
|
||
|
||
<!-- Claude Code tab -->
|
||
<div v-if="mcpClientTab === 'claude-code'">
|
||
<p class="settings-description">
|
||
<strong>Recommended — install the Grimoire plugin.</strong> One install wires up the MCP
|
||
connection, a session-start hook that surfaces your rules, and the Grimoire process-skills.
|
||
</p>
|
||
<ol>
|
||
<li>
|
||
Add the marketplace and install the plugin:
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ pluginInstallCommands }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(pluginInstallCommands, 'plugin-install')">
|
||
{{ copiedSnippetKey === 'plugin-install' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
When prompted, enter your <strong>Grimoire base URL</strong> (<code>{{ origin }}</code>),
|
||
an <strong>API key</strong> (generate one above), and optionally a
|
||
<strong>project id</strong> to scope the session-start context.
|
||
</li>
|
||
<li>
|
||
Restart Claude Code — <code>/mcp</code> shows <code>grimoire</code> connected and your
|
||
standing rules load at session start.
|
||
</li>
|
||
</ol>
|
||
|
||
<details class="mcp-advanced">
|
||
<summary>Customize</summary>
|
||
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Plugin marketplace (git URL)</span>
|
||
<input
|
||
v-model="pluginMarketplaceUrl"
|
||
type="text"
|
||
:placeholder="serverMarketplaceUrl || 'https://git.example.com/you/Grimoire.git'"
|
||
class="settings-input"
|
||
spellcheck="false"
|
||
/>
|
||
<span class="mcp-hint">
|
||
Pre-filled with this instance's repo. Override only if you host the plugin elsewhere.
|
||
</span>
|
||
</label>
|
||
|
||
<div class="mcp-client-config">
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Server name</span>
|
||
<input v-model="mcpServerName" type="text" placeholder="grimoire" class="settings-input" spellcheck="false" />
|
||
<span class="mcp-hint">
|
||
Local label for the MCP-only path below. Examples: <code>grimoire</code>, <code>grimoire-dev</code>.
|
||
</span>
|
||
</label>
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Scope</span>
|
||
<select v-model="mcpScope" class="settings-input">
|
||
<option value="user">user — available across all projects</option>
|
||
<option value="project">project — write to current repo's .mcp.json</option>
|
||
<option value="local">local — this machine + repo only</option>
|
||
</select>
|
||
<span class="mcp-hint">Where Claude Code stores the MCP-only registration.</span>
|
||
</label>
|
||
</div>
|
||
|
||
<p class="mcp-hint" style="margin-top: 0.75rem;">
|
||
<strong>Connect the MCP only (no plugin).</strong> You won't get the session-start rule
|
||
push or the Grimoire skills this way.
|
||
</p>
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
||
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
<p class="mcp-hint">
|
||
Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
|
||
</p>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Claude Desktop tab -->
|
||
<div v-else-if="mcpClientTab === 'claude-desktop'">
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Server name</span>
|
||
<input v-model="mcpServerName" type="text" placeholder="grimoire" class="settings-input" spellcheck="false" />
|
||
<span class="mcp-hint">The key used for this server in the config JSON below.</span>
|
||
</label>
|
||
<ol>
|
||
<li>
|
||
Add this block to your Claude Desktop MCP config file
|
||
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
|
||
<code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
|
||
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
Restart Claude Desktop. The Grimoire tools should appear in the available tools list.
|
||
</li>
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- ── Admin ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>What's running</h2>
|
||
<p class="section-desc">
|
||
The build serving this page. Paste the commit into a <code>:sha</code> image
|
||
lookup to check the registry and the app agree about what was published.
|
||
</p>
|
||
|
||
<div v-if="versionLoading" class="state-msg">Reading the ledger…</div>
|
||
<div v-else-if="versionError" class="error-msg">
|
||
{{ versionError }}
|
||
<button class="btn-ghost btn-compact version-retry" @click="loadVersionPanel">Try again</button>
|
||
</div>
|
||
<dl v-else-if="versionInfo" class="version-grid">
|
||
<dt>Version</dt>
|
||
<dd class="version-value">{{ versionInfo.version }}</dd>
|
||
|
||
<dt>Channel</dt>
|
||
<dd :class="versionInfo.channel === undefined ? 'version-unknown' : 'version-value'">
|
||
{{ versionInfo.channel ?? "unknown" }}
|
||
</dd>
|
||
|
||
<dt>Commit</dt>
|
||
<dd v-if="versionInfo.commit" class="version-value version-commit">
|
||
<span class="version-sha">{{ versionInfo.commit }}</span>
|
||
<button class="btn-ghost btn-compact" @click="copyCommit">
|
||
{{ commitCopied ? "Copied" : "Copy" }}
|
||
</button>
|
||
</dd>
|
||
<dd v-else class="version-unknown">unknown</dd>
|
||
|
||
<dt>Build</dt>
|
||
<!-- The ordering key, kept because its ABSENCE is the diagnostic one:
|
||
no key means this build is not part of any update order, which is
|
||
what a local or hand-built image looks like. `??` not `||` — 0 is
|
||
a legitimate key. -->
|
||
<dd :class="versionInfo.build === undefined ? 'version-unknown' : 'version-value'">
|
||
{{ versionInfo.build ?? "unknown" }}
|
||
</dd>
|
||
</dl>
|
||
<div v-else class="empty-msg">Nothing asked yet.</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Application URL</h2>
|
||
<p class="section-desc">
|
||
Public URL used in email links (invitations, password resets). Example: https://notes.example.com
|
||
</p>
|
||
<div class="field url-field">
|
||
<label for="base-url">Base URL</label>
|
||
<input
|
||
id="base-url"
|
||
v-model="baseUrl"
|
||
type="url"
|
||
placeholder="https://notes.example.com"
|
||
class="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
||
{{ savingBaseUrl ? "Saving..." : "Save" }}
|
||
</button>
|
||
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Plugin marketplace</h2>
|
||
<p class="section-desc">
|
||
Git URL of the repo that ships this Grimoire plugin (usually this app's own repo).
|
||
Shown to every user in <strong>MCP Access</strong> so the install command is
|
||
copyable. Example: https://git.example.com/you/Grimoire.git
|
||
</p>
|
||
<div class="field url-field">
|
||
<label for="marketplace-url">Marketplace git URL</label>
|
||
<input
|
||
id="marketplace-url"
|
||
v-model="adminMarketplaceUrl"
|
||
type="url"
|
||
placeholder="https://git.example.com/you/Grimoire.git"
|
||
class="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
|
||
</button>
|
||
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Database maintenance</h2>
|
||
<p class="section-desc">
|
||
A daily <code>VACUUM (ANALYZE)</code> over the high-churn tables (logs, notifications,
|
||
tokens, notes, version history) — on top of Postgres autovacuum — to reclaim space left
|
||
by the nightly cleanup sweeps and keep query plans fresh. Runs at the hour below (UTC),
|
||
just after trash purge.
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="dbMaintEnabled" />
|
||
Run scheduled maintenance daily
|
||
</label>
|
||
</div>
|
||
<div class="field url-field">
|
||
<label for="db-maint-hour">Run hour (UTC)</label>
|
||
<select id="db-maint-hour" v-model.number="dbMaintHour" class="fs-input input">
|
||
<option v-for="h in 24" :key="h - 1" :value="h - 1">
|
||
{{ String(h - 1).padStart(2, '0') }}:00
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||
{{ savingDbMaint ? "Saving..." : "Save" }}
|
||
</button>
|
||
<button class="btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||
{{ runningDbMaint ? "Running..." : "Run now" }}
|
||
</button>
|
||
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
<div v-if="dbMaintLastRun" class="db-maint-last">
|
||
<span class="db-maint-last-label">
|
||
Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }}
|
||
· {{ dbMaintLastRun.elapsed_ms }}ms
|
||
</span>
|
||
<ul class="db-maint-table-list">
|
||
<li v-for="t in dbMaintLastRun.tables" :key="t.table" :class="{ 'dm-failed': !t.ok }">
|
||
<code>{{ t.table }}</code>
|
||
<span class="dm-status">{{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="db-health">
|
||
<h3 class="subsection-label">
|
||
Table health
|
||
<span v-if="dbHealth" class="db-health-total">· database {{ formatBytes(dbHealth.db_bytes) }}</span>
|
||
</h3>
|
||
<p class="field-hint">
|
||
Dead-tuple ratio is bloat — rows left by updates/deletes not yet reclaimed.
|
||
Above {{ DEAD_PCT_WARN }}% on a large table means autovacuum is falling behind;
|
||
consider adding it to the maintenance set.
|
||
</p>
|
||
<p v-if="loadingHealth && !dbHealth" class="field-hint">Loading…</p>
|
||
<div v-else-if="dbHealth" class="db-health-scroll">
|
||
<table class="db-health-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Table</th><th class="num">Size</th><th class="num">Live</th>
|
||
<th class="num">Dead</th><th class="num">Dead %</th>
|
||
<th>Last vacuum</th><th>Last analyze</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="t in dbHealth.tables" :key="t.table" :class="{ 'dh-warn': t.dead_pct >= DEAD_PCT_WARN }">
|
||
<td><code>{{ t.table }}</code></td>
|
||
<td class="num">{{ formatBytes(t.total_bytes) }}</td>
|
||
<td class="num">{{ t.live.toLocaleString() }}</td>
|
||
<td class="num">{{ t.dead.toLocaleString() }}</td>
|
||
<td class="num">{{ t.dead_pct }}%</td>
|
||
<td>{{ t.last_vacuum ? new Date(t.last_vacuum).toLocaleString() : "—" }}</td>
|
||
<td>{{ t.last_analyze ? new Date(t.last_analyze).toLocaleString() : "—" }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Email / SMTP</h2>
|
||
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
|
||
<div class="smtp-grid">
|
||
<div class="field">
|
||
<label for="smtp-host">SMTP Host</label>
|
||
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-port">Port</label>
|
||
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-username">Username</label>
|
||
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-password">Password</label>
|
||
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-from-address">From Address</label>
|
||
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-from-name">From Name</label>
|
||
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Grimoire" class="fs-input input" />
|
||
</div>
|
||
</div>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" :checked="smtp.smtp_use_tls === 'true'" @change="smtp.smtp_use_tls = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
|
||
Use STARTTLS
|
||
</label>
|
||
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
|
||
</div>
|
||
<div class="actions" style="margin-bottom: 1.25rem;">
|
||
<button class="btn-primary" @click="saveSmtp" :disabled="savingSmtp">
|
||
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
|
||
</button>
|
||
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
<div class="test-email-section">
|
||
<h3 class="subsection-label">Test Email</h3>
|
||
<div class="test-email-row">
|
||
<input
|
||
v-model="testRecipient"
|
||
type="email"
|
||
placeholder="test@example.com"
|
||
class="fs-input input"
|
||
/>
|
||
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||
{{ sendingTest ? "Sending..." : "Send Test" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Forge Webhook</h2>
|
||
<p class="section-desc">
|
||
Forge connections are per-user (Settings → Integrations → Git
|
||
Forges). What stays instance-wide is the push webhook: create one on
|
||
the forge pointing at <code>/api/webhooks/forge</code> with this
|
||
secret, and snippets whose recorded files change get flagged for
|
||
re-verification.
|
||
</p>
|
||
<div class="smtp-grid">
|
||
<div class="field">
|
||
<label for="forge-webhook-secret">Webhook Secret</label>
|
||
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="fs-input input" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
|
||
{{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
|
||
</button>
|
||
<span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Users ── -->
|
||
<!-- ── Shared areas ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'areas'" class="settings-grid">
|
||
<section class="settings-section full-width">
|
||
<h2>Shared areas</h2>
|
||
<p class="field-hint">
|
||
The vocabulary every project's Systems can be filed under, so the same word means the
|
||
same thing everywhere. A project keeps its own name for an area — mapping records which
|
||
shared area it is, it never renames anything. Editing a name here re-derives its match
|
||
key, so existing mappings are kept but future name matching follows the new spelling.
|
||
</p>
|
||
|
||
<ul class="area-admin-list">
|
||
<li v-for="entry in canonStore.catalog" :key="entry.id" class="area-admin-row">
|
||
<template v-if="editingAreaId === entry.id">
|
||
<form class="area-admin-form" @submit.prevent="saveArea">
|
||
<input v-model="editAreaName" class="fs-input" aria-label="Area name" />
|
||
<textarea
|
||
v-model="editAreaDescription"
|
||
class="fs-input"
|
||
rows="2"
|
||
aria-label="Area description"
|
||
></textarea>
|
||
<div class="area-admin-actions">
|
||
<button type="submit" class="btn-primary btn-compact" :disabled="!editAreaName.trim() || savingArea">
|
||
{{ savingArea ? "Saving…" : "Save" }}
|
||
</button>
|
||
<button type="button" class="btn-ghost btn-compact" @click="editingAreaId = null">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</template>
|
||
<template v-else>
|
||
<div class="area-admin-body">
|
||
<div class="area-admin-name-row">
|
||
<span class="area-admin-name">{{ entry.name }}</span>
|
||
<code class="area-admin-slug" title="The match key. Names that reduce to this are the same area.">{{ entry.slug }}</code>
|
||
</div>
|
||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||
</div>
|
||
<button
|
||
class="btn-ghost btn-compact"
|
||
@click="startEditArea(entry.id, entry.name, entry.description)"
|
||
>Edit</button>
|
||
</template>
|
||
</li>
|
||
</ul>
|
||
<p v-if="!canonStore.catalog.length && !canonStore.loading" class="settings-empty">
|
||
No areas yet.
|
||
</p>
|
||
|
||
<form class="area-admin-form area-admin-create" @submit.prevent="createArea">
|
||
<input
|
||
v-model="newAreaName"
|
||
class="fs-input"
|
||
placeholder="New area name (e.g. Search & Indexing)"
|
||
aria-label="New area name"
|
||
/>
|
||
<textarea
|
||
v-model="newAreaDescription"
|
||
class="fs-input"
|
||
rows="2"
|
||
placeholder="What belongs in this area? One paragraph — a bare name is never enough."
|
||
aria-label="New area description"
|
||
></textarea>
|
||
<div class="area-admin-actions">
|
||
<button type="submit" class="btn-primary btn-compact" :disabled="!newAreaName.trim() || creatingArea">
|
||
{{ creatingArea ? "Adding…" : "Add area" }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- ── Platforms ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
|
||
<section class="settings-section full-width">
|
||
<h2>Platforms</h2>
|
||
<p class="field-hint">
|
||
What a project can be built on or ship as. A project's platforms decide which family
|
||
ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
|
||
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
|
||
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
|
||
markers is never detected — projects declare it themselves.
|
||
</p>
|
||
|
||
<ul class="area-admin-list">
|
||
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
|
||
<template v-if="editingPlatformId === entry.id">
|
||
<form class="area-admin-form" @submit.prevent="savePlatform">
|
||
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
|
||
<textarea
|
||
v-model="editPlatformDescription"
|
||
class="fs-input"
|
||
rows="2"
|
||
aria-label="Platform description"
|
||
></textarea>
|
||
<textarea
|
||
v-model="editPlatformMarkers"
|
||
class="fs-input area-admin-markers"
|
||
rows="3"
|
||
aria-label="Detection markers, one per line"
|
||
></textarea>
|
||
<div class="area-admin-actions">
|
||
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
|
||
{{ savingPlatform ? "Saving…" : "Save" }}
|
||
</button>
|
||
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = null">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</template>
|
||
<template v-else>
|
||
<div class="area-admin-body">
|
||
<div class="area-admin-name-row">
|
||
<span class="area-admin-name">{{ entry.name }}</span>
|
||
<code class="area-admin-slug" title="The match key, and the name tools and backups use.">{{ entry.slug }}</code>
|
||
</div>
|
||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||
<p class="area-admin-desc">
|
||
<template v-if="entry.markers.length">
|
||
Detected by
|
||
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
|
||
</template>
|
||
<template v-else>Declare-only — never detected.</template>
|
||
</p>
|
||
</div>
|
||
<button
|
||
class="btn-ghost btn-compact"
|
||
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
|
||
>Edit</button>
|
||
</template>
|
||
</li>
|
||
</ul>
|
||
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
|
||
No platforms yet.
|
||
</p>
|
||
|
||
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
|
||
<input
|
||
v-model="newPlatformName"
|
||
class="fs-input"
|
||
placeholder="New platform name (e.g. Flutter app)"
|
||
aria-label="New platform name"
|
||
/>
|
||
<textarea
|
||
v-model="newPlatformDescription"
|
||
class="fs-input"
|
||
rows="2"
|
||
placeholder="What makes a project this platform? One sentence."
|
||
aria-label="New platform description"
|
||
></textarea>
|
||
<textarea
|
||
v-model="newPlatformMarkers"
|
||
class="fs-input area-admin-markers"
|
||
rows="2"
|
||
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
|
||
aria-label="New platform markers, one per line"
|
||
></textarea>
|
||
<div class="area-admin-actions">
|
||
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
|
||
{{ creatingPlatform ? "Adding…" : "Add platform" }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Registration</h2>
|
||
<div class="registration-row">
|
||
<div class="registration-info">
|
||
<p class="registration-status">
|
||
Registration is currently
|
||
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
|
||
{{ registrationOpen ? "open" : "closed" }}
|
||
</strong>
|
||
</p>
|
||
<p class="field-hint">When closed, new users can only be added by an administrator.</p>
|
||
</div>
|
||
<button
|
||
class="btn-primary btn-toggle"
|
||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||
@click="toggleRegistration"
|
||
:disabled="toggling"
|
||
>
|
||
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Invite User</h2>
|
||
<form class="invite-form" @submit.prevent="sendInvite">
|
||
<input
|
||
v-model="inviteEmail"
|
||
type="email"
|
||
placeholder="Email address"
|
||
class="fs-input input invite-input"
|
||
required
|
||
:disabled="sendingInvite"
|
||
/>
|
||
<button type="submit" class="btn-primary" :disabled="sendingInvite || !inviteEmail.trim()">
|
||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||
</button>
|
||
</form>
|
||
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
|
||
<div v-if="invitations.length > 0" class="invite-list">
|
||
<h3 class="subsection-label">Pending Invitations</h3>
|
||
<table class="users-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Email</th>
|
||
<th class="hide-mobile">Sent</th>
|
||
<th class="hide-mobile">Expires</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="inv in invitations" :key="inv.id">
|
||
<td class="cell-email">{{ inv.email }}</td>
|
||
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||
<td class="cell-actions">
|
||
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Users</h2>
|
||
<div v-if="usersLoading" class="loading-msg">Loading users...</div>
|
||
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
|
||
<table v-else class="users-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Username</th>
|
||
<th class="hide-mobile">Email</th>
|
||
<th>Role</th>
|
||
<th class="hide-mobile">Joined</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="u in users" :key="u.id">
|
||
<td class="cell-username">{{ u.username }}</td>
|
||
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
|
||
<td>
|
||
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
|
||
{{ u.role }}
|
||
</span>
|
||
</td>
|
||
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
|
||
<td class="cell-actions">
|
||
<template v-if="u.id === authStore.user?.id">
|
||
<span class="you-label">You</span>
|
||
</template>
|
||
<template v-else-if="confirmDeleteId === u.id">
|
||
<button class="btn-danger btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||
</button>
|
||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||
</template>
|
||
<template v-else>
|
||
<button class="btn-ghost btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
|
||
</template>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Logs ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'logs'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width stats-section">
|
||
<h2>Overview</h2>
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<span class="stat-count">{{ logStats.total.toLocaleString() }}</span>
|
||
<span class="stat-label">Total</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-count stat-audit">{{ logStats.audit.toLocaleString() }}</span>
|
||
<span class="stat-label">Audit</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-count stat-usage">{{ logStats.usage.toLocaleString() }}</span>
|
||
<span class="stat-label">Usage</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-count stat-error">{{ logStats.error.toLocaleString() }}</span>
|
||
<span class="stat-label">Error</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Log Entries</h2>
|
||
<div class="filter-bar">
|
||
<select v-model="logCategory" class="filter-select input">
|
||
<option value="">All categories</option>
|
||
<option value="audit">Audit</option>
|
||
<option value="usage">Usage</option>
|
||
<option value="error">Error</option>
|
||
</select>
|
||
<input v-model="logSearch" type="text" placeholder="Search logs..." class="filter-input input" />
|
||
<input v-model="logDateFrom" type="date" class="filter-date input" title="From date" />
|
||
<input v-model="logDateTo" type="date" class="filter-date input" title="To date" />
|
||
<button
|
||
v-if="logCategory || logSearch || logDateFrom || logDateTo"
|
||
class="btn-secondary"
|
||
@click="clearLogFilters"
|
||
>Clear</button>
|
||
</div>
|
||
|
||
<div v-if="logsLoading" class="loading-msg">Loading logs...</div>
|
||
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
|
||
<template v-else>
|
||
<table class="users-table logs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Category</th>
|
||
<th class="hide-mobile">User</th>
|
||
<th>Action / Endpoint</th>
|
||
<th class="hide-mobile">Status</th>
|
||
<th class="hide-mobile">Duration</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="entry in logs" :key="entry.id">
|
||
<tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
|
||
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
|
||
<td>
|
||
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
|
||
</td>
|
||
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
|
||
<td class="cell-action">
|
||
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
|
||
{{ logDisplayLabel(entry) }}
|
||
</td>
|
||
<td class="hide-mobile cell-status">
|
||
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
|
||
{{ entry.status_code }}
|
||
</span>
|
||
<span v-else>—</span>
|
||
</td>
|
||
<td class="hide-mobile cell-duration">
|
||
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
|
||
</td>
|
||
</tr>
|
||
<tr v-if="expandedLogId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
|
||
<td colspan="6">
|
||
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
|
||
<pre v-if="entry.details" class="detail-json">{{ formatLogDetails(entry.details) }}</pre>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
<PaginationBar
|
||
:total="logTotal"
|
||
:limit="logLimit"
|
||
:offset="logOffset"
|
||
@update:offset="logOffset = $event"
|
||
/>
|
||
</template>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Groups ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'groups'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Groups</h2>
|
||
<p class="section-desc">Manage platform-wide groups for sharing projects and notes.</p>
|
||
|
||
<!-- Create group form -->
|
||
<div class="group-create-form">
|
||
<input v-model="newGroupName" class="input-field" placeholder="Group name" maxlength="100" @keydown.enter="createNewGroup" />
|
||
<input v-model="newGroupDesc" class="input-field" placeholder="Description (optional)" maxlength="255" @keydown.enter="createNewGroup" />
|
||
<button class="btn-primary" @click="createNewGroup" :disabled="creatingGroup || !newGroupName.trim()">
|
||
{{ creatingGroup ? 'Creating…' : 'Create Group' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Groups list -->
|
||
<div v-if="groupsLoading" class="loading-msg">Loading groups…</div>
|
||
<div v-else-if="!groups.length" class="empty-msg">No groups yet.</div>
|
||
<div v-else class="groups-list">
|
||
<div v-for="g in groups" :key="g.id" class="group-card">
|
||
<div class="group-card-header">
|
||
<div class="group-card-info">
|
||
<span class="group-name">{{ g.name }}</span>
|
||
<span class="group-meta">{{ g.member_count }} member{{ g.member_count !== 1 ? 's' : '' }}</span>
|
||
<span v-if="g.description" class="group-desc">{{ g.description }}</span>
|
||
</div>
|
||
<div class="group-card-actions">
|
||
<button class="btn-ghost btn-compact" @click="toggleGroupExpand(g)">
|
||
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
|
||
</button>
|
||
<button class="btn-danger-outline btn-compact" @click="deleteGroupConfirm(g)">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Members panel -->
|
||
<div v-if="expandedGroupId === g.id" class="group-members-panel">
|
||
<div class="members-search">
|
||
<div class="member-search-wrap">
|
||
<input
|
||
v-model="groupMemberSearch"
|
||
class="input-field"
|
||
placeholder="Search user to add…"
|
||
@input="debounceGroupMemberSearch"
|
||
autocomplete="off"
|
||
/>
|
||
<ul v-if="groupMemberResults.length" class="member-results">
|
||
<li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
|
||
<span class="member-result-name">{{ u.username }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<select v-model="groupMemberRole" class="role-select">
|
||
<option value="member">Member</option>
|
||
<option value="owner">Owner</option>
|
||
</select>
|
||
</div>
|
||
<ul class="members-list">
|
||
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
|
||
<span class="member-name">{{ m.username }}</span>
|
||
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
|
||
<button class="btn-danger-outline btn-compact" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||
</li>
|
||
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
</div><!-- end .settings-content -->
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Settings root layout */
|
||
.settings-root {
|
||
display: flex;
|
||
gap: 0;
|
||
max-width: 1100px;
|
||
margin: 2rem auto;
|
||
padding: 0 1.5rem;
|
||
align-items: flex-start;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.settings-sidebar {
|
||
width: 175px;
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
top: 1.5rem;
|
||
padding-right: 1rem;
|
||
}
|
||
.sidebar-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.sidebar-group-label {
|
||
font-size: 0.65rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
color: var(--fs-text-tertiary);
|
||
padding: 0.25rem 0.75rem 0.2rem;
|
||
}
|
||
.sidebar-item {
|
||
display: block;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: 0.4rem 0.75rem;
|
||
border: none;
|
||
border-left: 2px solid transparent;
|
||
background: none;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
color: var(--fs-text-secondary);
|
||
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
|
||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.sidebar-item:hover {
|
||
color: var(--fs-text-primary);
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
.sidebar-item.active {
|
||
color: var(--fs-accent-fg);
|
||
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
||
border-left-color: var(--fs-accent);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Content panel */
|
||
.settings-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Two-column grid — small sections pair up, full-width sections span both */
|
||
.settings-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1.25rem;
|
||
align-items: start;
|
||
}
|
||
.settings-section {
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-lg);
|
||
padding: 1.25rem;
|
||
}
|
||
.settings-section.full-width {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.settings-section h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
/* What's running — a definition list of instance facts. Spacing/geometry only;
|
||
colour and type come from the tokens. */
|
||
.version-grid {
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
gap: 0.4rem 1rem;
|
||
margin: 0;
|
||
align-items: baseline;
|
||
}
|
||
.version-grid dt {
|
||
font-size: 0.8rem;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.version-grid dd {
|
||
margin: 0;
|
||
font-size: 0.875rem;
|
||
font-family: var(--fs-font-mono);
|
||
color: var(--fs-text-primary);
|
||
}
|
||
/* An absent field reads as absent — never as a blank, and never styled to look
|
||
like a value it does not have. */
|
||
.version-grid dd.version-unknown {
|
||
font-family: inherit;
|
||
font-style: italic;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
.version-commit {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.version-sha {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.version-retry {
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.section-desc {
|
||
margin: 0 0 1rem;
|
||
font-size: 0.875rem;
|
||
color: var(--fs-text-secondary);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.field {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.field label {
|
||
display: block;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
margin-bottom: 0.35rem;
|
||
color: var(--fs-text-primary);
|
||
}
|
||
/* remainder over .fs-input (components.css) */
|
||
.input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.65rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
/* Save: Moss action-primary per Hybrid */
|
||
.db-maint-table-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
.db-maint-table-list li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
font-size: 0.82rem;
|
||
padding: 0.2rem 0;
|
||
}
|
||
.db-maint-table-list .dm-status { color: var(--fs-text-tertiary); }
|
||
.db-maint-table-list li.dm-failed .dm-status { color: var(--fs-error); }
|
||
|
||
/* DB table-health readout */
|
||
.db-health { margin-top: 1.5rem; }
|
||
.db-health-total { color: var(--fs-text-tertiary); font-weight: 400; }
|
||
.db-health-scroll { overflow-x: auto; margin-top: 0.5rem; }
|
||
.db-health-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.82rem;
|
||
}
|
||
.db-health-table th, .db-health-table td {
|
||
text-align: left;
|
||
padding: 0.35rem 0.6rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
white-space: nowrap;
|
||
}
|
||
.db-health-table th {
|
||
font-size: 0.72rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--fs-text-tertiary);
|
||
font-weight: 500;
|
||
}
|
||
.db-health-table td.num, .db-health-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.db-health-table tr.dh-warn td { color: var(--fs-warning); }
|
||
.db-health-table tr.dh-warn td:first-child code { color: var(--fs-warning); }
|
||
.btn-warn:hover:not(:disabled) {
|
||
background: var(--fs-warning);
|
||
color: var(--fs-text-on-action);
|
||
}
|
||
|
||
.saved-msg {
|
||
color: var(--fs-success);
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
}
|
||
.input-error { border-color: var(--fs-error); }
|
||
.input-error:focus { border-color: var(--fs-error); }
|
||
.error-hint {
|
||
margin: 0.3rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: var(--fs-error);
|
||
}
|
||
|
||
/* Data buttons */
|
||
.data-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
.hidden-file-input { display: none; }
|
||
|
||
/* Checkboxes */
|
||
.checkbox-field { margin-bottom: 1rem; }
|
||
.checkbox-field label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.9rem;
|
||
color: var(--fs-text-primary);
|
||
cursor: pointer;
|
||
}
|
||
.checkbox-field input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
accent-color: var(--fs-accent);
|
||
}
|
||
|
||
/* Search test */
|
||
.url-chip {
|
||
font-size: 0.8rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 4px;
|
||
}
|
||
.not-configured {
|
||
opacity: 0.75;
|
||
}
|
||
.search-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.search-row .input { flex: 1; }
|
||
.search-error {
|
||
font-size: 0.875rem;
|
||
color: var(--fs-error);
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
.search-results {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.search-result {
|
||
padding: 0.65rem 0.85rem;
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
}
|
||
.result-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.result-title {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: var(--fs-accent);
|
||
text-decoration: none;
|
||
word-break: break-word;
|
||
}
|
||
.result-title:hover { text-decoration: underline; }
|
||
.result-host {
|
||
font-size: 0.75rem;
|
||
color: var(--fs-text-tertiary);
|
||
white-space: nowrap;
|
||
}
|
||
.result-snippet {
|
||
margin: 0;
|
||
font-size: 0.82rem;
|
||
color: var(--fs-text-secondary);
|
||
line-height: 1.45;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Application URL — constrain input width */
|
||
.url-field { max-width: 480px; }
|
||
|
||
/* SMTP grid */
|
||
.smtp-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 0 1rem;
|
||
}
|
||
@media (max-width: 700px) {
|
||
.smtp-grid { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.smtp-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* Test email */
|
||
.subsection-label {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.test-email-section {
|
||
border-top: 1px solid var(--fs-border-color);
|
||
padding-top: 1rem;
|
||
}
|
||
.test-email-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
max-width: 480px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.settings-root {
|
||
flex-direction: column;
|
||
padding: 0 1rem;
|
||
}
|
||
.settings-sidebar {
|
||
width: 100%;
|
||
position: static;
|
||
padding-right: 0;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
margin-bottom: 1rem;
|
||
}
|
||
.sidebar-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.sidebar-group-label {
|
||
width: 100%;
|
||
}
|
||
.sidebar-item {
|
||
width: auto;
|
||
border-left: none;
|
||
border-bottom: 2px solid transparent;
|
||
border-radius: var(--fs-radius-sm) var(--fs-radius-sm) 0 0;
|
||
font-size: 0.82rem;
|
||
padding: 0.35rem 0.7rem;
|
||
}
|
||
.sidebar-item.active {
|
||
border-bottom-color: var(--fs-accent);
|
||
background: var(--fs-accent-soft);
|
||
}
|
||
.settings-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.settings-section.full-width {
|
||
grid-column: auto;
|
||
}
|
||
.smtp-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* Users panel */
|
||
.registration-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
}
|
||
.registration-info { flex: 1; }
|
||
.registration-status { margin: 0; font-size: 0.95rem; }
|
||
.text-success { color: var(--fs-success); }
|
||
.text-muted { color: var(--fs-text-tertiary); }
|
||
.invite-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.invite-input { flex: 1; }
|
||
.invite-list { margin-top: 1rem; }
|
||
.subsection-label {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.users-table { width: 100%; border-collapse: collapse; }
|
||
.users-table th {
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--fs-text-tertiary);
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
}
|
||
.users-table td {
|
||
padding: 0.65rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
font-size: 0.9rem;
|
||
}
|
||
.users-table tbody tr:last-child td { border-bottom: none; }
|
||
.cell-username { font-weight: 500; }
|
||
.cell-email { color: var(--fs-text-secondary); }
|
||
.cell-date { color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||
.cell-actions { white-space: nowrap; }
|
||
.role-badge {
|
||
display: inline-block;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: var(--fs-radius-sm);
|
||
}
|
||
.role-admin {
|
||
color: var(--fs-accent-fg);
|
||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||
}
|
||
.role-user {
|
||
color: var(--fs-text-tertiary);
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
.you-label { font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
|
||
|
||
/* Logs panel */
|
||
.stats-section { padding: 1rem 1.25rem; }
|
||
.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||
.stat-card {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
}
|
||
.stat-count { font-size: 1.5rem; font-weight: 500; color: var(--fs-text-primary); }
|
||
.stat-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
.stat-audit { color: var(--fs-accent); }
|
||
.stat-usage { color: var(--fs-success); }
|
||
.stat-error { color: var(--fs-error); }
|
||
|
||
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||
.filter-select { min-width: 140px; }
|
||
.filter-input { flex: 1; min-width: 150px; }
|
||
.filter-date { width: 140px; }
|
||
|
||
.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||
.logs-table th {
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--fs-text-tertiary);
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
}
|
||
.logs-table td {
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
font-size: 0.85rem;
|
||
}
|
||
.logs-table tbody tr:last-child td { border-bottom: none; }
|
||
.log-row { cursor: pointer; transition: background 0.1s; }
|
||
.log-row:hover { background: var(--fs-surface-raised); }
|
||
.row-expanded { background: var(--fs-surface-raised); }
|
||
.cell-time { white-space: nowrap; color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||
.cell-user { color: var(--fs-text-secondary); }
|
||
.cell-action { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
||
.cell-duration { color: var(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; }
|
||
.text-error { color: var(--fs-error); }
|
||
/* Bare by design: a `<tr>` has nothing to style
|
||
that its cells don't carry. */
|
||
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); }
|
||
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; }
|
||
.detail-json {
|
||
margin: 0; padding: 0.75rem;
|
||
background: var(--fs-surface-page); border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm); font-size: 0.8rem;
|
||
overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px;
|
||
}
|
||
.category-badge {
|
||
display: inline-block;
|
||
font-size: 0.65rem; font-weight: 500;
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm);
|
||
}
|
||
.cat-audit { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
|
||
.cat-usage { color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 15%, transparent); }
|
||
.cat-error { color: var(--fs-error-fg); background: color-mix(in srgb, var(--fs-error) 15%, transparent); }
|
||
.method-tag {
|
||
display: inline-block;
|
||
font-size: 0.65rem; font-weight: 500; font-family: monospace;
|
||
padding: 0.05rem 0.25rem; border-radius: 3px;
|
||
background: var(--fs-surface-raised); color: var(--fs-text-tertiary);
|
||
margin-right: 0.25rem;
|
||
}
|
||
|
||
/* ── Groups tab ──────────────────────────────────────────────── */
|
||
/* Moss action-primary per Hybrid */
|
||
|
||
.input-field {
|
||
width: 100%;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.input-field:focus { border-color: var(--fs-accent); }
|
||
|
||
.group-create-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1.5rem;
|
||
align-items: center;
|
||
}
|
||
.group-create-form .input-field { flex: 1; min-width: 160px; }
|
||
|
||
.loading-msg, .empty-msg {
|
||
color: var(--fs-text-tertiary);
|
||
font-size: 0.88rem;
|
||
padding: 0.5rem 0;
|
||
}
|
||
|
||
.groups-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.group-card {
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-lg);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.group-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
|
||
.group-card-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.group-name {
|
||
font-weight: 500;
|
||
font-size: 0.9rem;
|
||
color: var(--fs-text-primary);
|
||
}
|
||
.group-meta {
|
||
font-size: 0.78rem;
|
||
color: var(--fs-text-tertiary);
|
||
white-space: nowrap;
|
||
}
|
||
.group-desc {
|
||
font-size: 0.82rem;
|
||
color: var(--fs-text-tertiary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.group-card-actions {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.member-search-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
.member-results {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--fs-surface-hover);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 6px;
|
||
margin-top: 2px;
|
||
list-style: none;
|
||
padding: 0.25rem 0;
|
||
z-index: 10;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.member-result-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.45rem 0.8rem;
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
.member-result-item:hover { background: var(--fs-surface-hover); }
|
||
.member-result-name { font-weight: 500; font-size: 0.85rem; }
|
||
|
||
.role-select {
|
||
padding: 0.4rem 0.5rem;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 6px;
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.members-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.member-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.4rem 0.5rem;
|
||
border-radius: 6px;
|
||
background: var(--fs-surface-hover);
|
||
}
|
||
|
||
.member-name { flex: 1; font-size: 0.88rem; font-weight: 500; color: var(--fs-text-primary); }
|
||
|
||
.member-role-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); }
|
||
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); }
|
||
|
||
.members-empty {
|
||
color: var(--fs-text-tertiary);
|
||
font-size: 0.82rem;
|
||
padding: 0.25rem 0.5rem;
|
||
}
|
||
|
||
/* API Keys tab */
|
||
.api-key-create-form {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.api-key-scope-select {
|
||
display: flex;
|
||
gap: 1rem;
|
||
}
|
||
.api-key-scope-select label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
cursor: pointer;
|
||
}
|
||
.api-key-reveal {
|
||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||
border-radius: var(--fs-radius-lg);
|
||
padding: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.api-key-value-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.api-key-value {
|
||
flex: 1;
|
||
background: var(--fs-surface-hover);
|
||
padding: 0.4rem 0.6rem;
|
||
border-radius: var(--fs-radius-sm);
|
||
font-size: 0.85rem;
|
||
word-break: break-all;
|
||
}
|
||
.api-keys-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-top: 1rem;
|
||
}
|
||
.api-keys-table th, .api-keys-table td {
|
||
text-align: left;
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
font-size: 0.9rem;
|
||
}
|
||
.api-keys-table th { font-weight: 500; opacity: 0.7; }
|
||
.scope-badge {
|
||
display: inline-block;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 9999px;
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
}
|
||
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
|
||
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
|
||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
||
.mcp-code {
|
||
margin-top: 0.4rem;
|
||
padding: 0.55rem 0.75rem;
|
||
background: color-mix(in srgb, var(--fs-text-primary) 6%, transparent);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 6px;
|
||
font-size: 0.82rem;
|
||
font-family: monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
overflow-x: auto;
|
||
flex: 1;
|
||
}
|
||
.mcp-client-tabs {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
margin-bottom: 1rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
}
|
||
.mcp-client-tab {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0.5rem 0.9rem;
|
||
font-size: 0.85rem;
|
||
color: var(--fs-text-tertiary);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.mcp-client-tab:hover { color: var(--fs-text-primary); }
|
||
.mcp-client-tab.active {
|
||
color: var(--fs-accent);
|
||
border-bottom-color: var(--fs-accent);
|
||
font-weight: 500;
|
||
}
|
||
.mcp-url-block { margin-top: 0.25rem; }
|
||
.mcp-url-label {
|
||
display: block;
|
||
font-size: 0.85rem;
|
||
opacity: 0.7;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.mcp-client-config {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
margin: 1rem 0 1.5rem;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.mcp-client-config { grid-template-columns: 1fr; }
|
||
}
|
||
.mcp-config-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
.mcp-config-label {
|
||
font-size: 0.85rem;
|
||
opacity: 0.7;
|
||
}
|
||
.mcp-code-row {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 0.4rem;
|
||
margin-top: 0.4rem;
|
||
}
|
||
.mcp-code-row .mcp-code { margin-top: 0; }
|
||
.mcp-code-row .btn-sm { white-space: nowrap; }
|
||
.mcp-advanced {
|
||
margin-top: 1.25rem;
|
||
border-top: 1px solid var(--fs-border-color);
|
||
padding-top: 0.75rem;
|
||
}
|
||
.mcp-advanced summary {
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
opacity: 0.75;
|
||
user-select: none;
|
||
}
|
||
.mcp-advanced summary:hover { opacity: 1; }
|
||
.mcp-advanced[open] summary { margin-bottom: 0.5rem; }
|
||
.mcp-hint {
|
||
margin-top: 0.5rem;
|
||
font-size: 0.8rem;
|
||
opacity: 0.7;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Shared areas ──────────────────────────────────────────────────
|
||
The slug is shown deliberately: it is what decides whether two names are
|
||
the same area, and an admin renaming an entry needs to see it move. */
|
||
.area-admin-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||
.area-admin-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: var(--fs-space-3);
|
||
padding: var(--fs-space-3);
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-md);
|
||
}
|
||
.area-admin-body { flex: 1; min-width: 0; }
|
||
.area-admin-name-row { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||
.area-admin-name { color: var(--fs-text-primary); }
|
||
.area-admin-slug {
|
||
font-family: var(--fs-font-mono);
|
||
font-size: 0.72rem;
|
||
color: var(--fs-text-tertiary);
|
||
background: var(--fs-surface-code-inline);
|
||
border-radius: var(--fs-radius-sm);
|
||
padding: 0.05rem 0.35rem;
|
||
}
|
||
.area-admin-desc { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||
.area-admin-create { margin-top: var(--fs-space-4); }
|
||
.area-admin-actions { display: flex; gap: 0.4rem; }
|
||
.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
|
||
.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
|
||
|
||
/* The retrieval tuning trail. Reads as a record, not a control panel:
|
||
the operator is reviewing what was done, and the reason is the part worth
|
||
reading, so it gets the full-width line under a compact header row. */
|
||
.tuning-history {
|
||
margin-top: var(--fs-space-5);
|
||
padding-top: var(--fs-space-4);
|
||
border-top: 1px solid var(--fs-border-color);
|
||
}
|
||
.tuning-history-title {
|
||
margin: 0 0 var(--fs-space-2);
|
||
font-size: 0.95rem;
|
||
color: var(--fs-text-primary);
|
||
}
|
||
.tuning-list {
|
||
list-style: none;
|
||
margin: var(--fs-space-3) 0 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--fs-space-2);
|
||
}
|
||
.tuning-item {
|
||
padding: var(--fs-space-3);
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-md);
|
||
}
|
||
.tuning-item-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
flex-wrap: wrap;
|
||
gap: var(--fs-space-2);
|
||
font-size: 0.85rem;
|
||
}
|
||
.tuning-surface {
|
||
font-family: var(--fs-font-mono);
|
||
color: var(--fs-text-primary);
|
||
}
|
||
.tuning-dial,
|
||
.tuning-move {
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.tuning-actor {
|
||
color: var(--fs-text-tertiary);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
padding: 0 0.4rem;
|
||
}
|
||
/* The operator's own changes are marked, because "did I do this, or did the
|
||
session?" is the first question this list is asked. */
|
||
.tuning-actor.is-human {
|
||
color: var(--fs-accent);
|
||
border-color: var(--fs-accent);
|
||
}
|
||
/* A shipped default that moved between releases. Muted rather than
|
||
accented: it is the answer to "did I do this?" being NO for both of the
|
||
other two, and it wants to be legible without competing with the changes
|
||
somebody actually made. */
|
||
.tuning-actor.is-release {
|
||
color: var(--fs-text-secondary);
|
||
border-style: dashed;
|
||
}
|
||
.tuning-when { margin-left: auto; color: var(--fs-text-tertiary); }
|
||
.tuning-reason {
|
||
margin: var(--fs-space-2) 0 0;
|
||
font-size: 0.85rem;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
/* Stale calibration. Tinted rather than loud: the numbers still work,
|
||
they have merely stopped being measurements — that is a "come back to this",
|
||
not an outage. It only renders when something is actually stale, which is
|
||
what earns it the tint at all. */
|
||
.calibration-warning {
|
||
margin-top: var(--fs-space-5);
|
||
padding: var(--fs-space-3);
|
||
border: 1px solid var(--fs-warning);
|
||
border-radius: var(--fs-radius-md);
|
||
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
|
||
}
|
||
.conn-token-lost {
|
||
margin-left: var(--fs-space-2);
|
||
font-size: var(--fs-size-body-sm);
|
||
color: var(--fs-warning-fg);
|
||
}
|
||
.calibration-warning-title {
|
||
margin: 0 0 var(--fs-space-2);
|
||
font-size: 0.95rem;
|
||
color: var(--fs-warning-fg);
|
||
}
|
||
.calibration-list {
|
||
list-style: none;
|
||
margin: var(--fs-space-2) 0 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--fs-space-1);
|
||
font-size: 0.85rem;
|
||
}
|
||
.calibration-list li {
|
||
display: flex;
|
||
align-items: baseline;
|
||
flex-wrap: wrap;
|
||
gap: var(--fs-space-2);
|
||
}
|
||
.calibration-why { color: var(--fs-text-secondary); }
|
||
</style>
|