Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions apps/dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ export type Schedule = {
max_concurrency: number;
timeout_ms?: number;
digest_mode: DigestMode;
routing_profile?: string;
fetch_config: FetchConfig;
source_states: Record<string, SourceState>;
max_catchup_runs: number;
Expand Down Expand Up @@ -671,6 +672,7 @@ export type CreateScheduleRequest = {
max_concurrency?: number;
timeout_ms?: number;
digest_mode?: DigestMode;
routing_profile?: string;
fetch_config?: Partial<FetchConfig>;
max_catchup_runs?: number;
};
Expand All @@ -688,6 +690,7 @@ export type UpdateScheduleRequest = {
max_concurrency?: number;
timeout_ms?: number | null;
digest_mode?: DigestMode;
routing_profile?: string;
fetch_config?: Partial<FetchConfig>;
max_catchup_runs?: number;
};
Expand Down Expand Up @@ -756,6 +759,38 @@ export type QuotaListResponse = {
quotas: QuotaStatus[];
};

// ── Router types ────────────────────────────────────────────────────

export type RouterStatus = {
enabled: boolean;
default_profile: string;
classifier: {
provider: string;
model: string;
connected: boolean;
avg_latency_ms?: number;
};
tiers: Record<string, string[]>;
thresholds: Record<string, number>;
};

export type ClassifyResult = {
tier: string;
scores: Record<string, number>;
resolved_model: string;
latency_ms: number;
};

export type RouterDecision = {
timestamp: string;
prompt_snippet: string;
profile: string;
tier: string;
model: string;
latency_ms: number;
bypassed: boolean;
};

// ── API functions ──────────────────────────────────────────────────

export const api = {
Expand Down Expand Up @@ -871,4 +906,11 @@ export const api = {
// Provider listing
providers: () => get<{ providers: string[]; count: number }>("/v1/models"),
roles: () => get<{ roles: Record<string, string> }>("/v1/models/roles"),

// Router
routerStatus: () => get<RouterStatus>("/v1/router/status"),
classifyPrompt: (prompt: string) =>
post<ClassifyResult>("/v1/router/classify", { prompt }),
routerDecisions: (limit = 100) =>
get<{ decisions: RouterDecision[]; count: number }>(`/v1/router/decisions?limit=${limit}`),
};
14 changes: 14 additions & 0 deletions apps/dashboard/src/pages/ScheduleDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const editMissedPolicy = ref<MissedPolicy>("run_once");
const editDigestMode = ref<DigestMode>("full");
const editMaxConcurrency = ref(1);
const editMaxCatchupRuns = ref(5);
const editRoutingProfile = ref("");
const editTimeoutMs = ref<number | null>(null);
const editSubmitting = ref(false);
const editError = ref("");
Expand All @@ -49,6 +50,7 @@ function startEdit() {
editDigestMode.value = s.digest_mode;
editMaxConcurrency.value = s.max_concurrency;
editMaxCatchupRuns.value = s.max_catchup_runs;
editRoutingProfile.value = s.routing_profile ?? "";
editTimeoutMs.value = s.timeout_ms ?? null;
editError.value = "";
editing.value = true;
Expand Down Expand Up @@ -83,6 +85,7 @@ async function submitEdit() {
digest_mode: editDigestMode.value,
max_concurrency: editMaxConcurrency.value,
max_catchup_runs: editMaxCatchupRuns.value,
routing_profile: editRoutingProfile.value === "" ? null : editRoutingProfile.value,
timeout_ms: editTimeoutMs.value,
};

Expand Down Expand Up @@ -368,6 +371,17 @@ function goToRun(runId?: string) {
<label>Max Catch-up Runs</label>
<input type="number" v-model.number="editMaxCatchupRuns" min="1" max="100" />
</div>
<div class="edit-field">
<label>Routing Profile</label>
<select v-model="editRoutingProfile">
<option value="">Default (inherit)</option>
<option value="auto">Auto</option>
<option value="eco">Eco</option>
<option value="premium">Premium</option>
<option value="free">Free</option>
<option value="reasoning">Reasoning</option>
</select>
</div>
</div>

<div class="edit-field">
Expand Down
14 changes: 14 additions & 0 deletions apps/dashboard/src/pages/Schedules.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const formMissedPolicy = ref<"skip" | "run_once" | "catch_up">("run_once");
const formDigestMode = ref<"full" | "changes_only">("full");
const formMaxConcurrency = ref(1);
const formMaxCatchupRuns = ref(5);
const formRoutingProfile = ref("");
const formSubmitting = ref(false);
const formError = ref("");

Expand Down Expand Up @@ -90,6 +91,7 @@ function openForm() {
formDigestMode.value = "full";
formMaxConcurrency.value = 1;
formMaxCatchupRuns.value = 5;
formRoutingProfile.value = "";
formError.value = "";
}

Expand Down Expand Up @@ -121,6 +123,7 @@ async function submitForm() {
digest_mode: formDigestMode.value,
max_concurrency: formMaxConcurrency.value,
max_catchup_runs: formMaxCatchupRuns.value,
routing_profile: formRoutingProfile.value === "" ? undefined : formRoutingProfile.value,
};

formSubmitting.value = true;
Expand Down Expand Up @@ -299,6 +302,17 @@ function goToSchedule(id: string) {
<label>Max Catch-up Runs</label>
<input type="number" v-model.number="formMaxCatchupRuns" min="1" max="100" />
</div>
<div class="field">
<label>Routing Profile</label>
<select v-model="formRoutingProfile">
<option value="">Default (inherit)</option>
<option value="auto">Auto</option>
<option value="eco">Eco</option>
<option value="premium">Premium</option>
<option value="free">Free</option>
<option value="reasoning">Reasoning</option>
</select>
</div>
</div>

<div class="field toggle-field">
Expand Down
138 changes: 137 additions & 1 deletion apps/dashboard/src/pages/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { ref, computed, onMounted } from "vue";
import { api, ApiError, setApiToken, getApiToken } from "@/api/client";
import type { SystemInfo, ReadinessResponse } from "@/api/client";
import type { RouterStatus, RouterDecision } from "@/api/client";
import Card from "@/components/Card.vue";
import LoadingPanel from "@/components/LoadingPanel.vue";
import ConfigEditor from "@/components/ConfigEditor.vue";
Expand All @@ -20,6 +21,27 @@ const tokenSaved = ref(false);
// Restart
const restarting = ref(false);

// Router
const routerStatus = ref<RouterStatus | null>(null);
const routerDecisions = ref<RouterDecision[]>([]);
const routerLoading = ref(false);
const routerError = ref("");
const decisionsExpanded = ref(false);

async function loadRouter() {
routerLoading.value = true;
routerError.value = "";
try {
routerStatus.value = await api.routerStatus();
const res = await api.routerDecisions(20);
routerDecisions.value = res.decisions;
} catch (e: unknown) {
routerError.value = e instanceof ApiError ? e.friendly : String(e);
} finally {
routerLoading.value = false;
}
}

const generatedToml = computed(() => {
if (!sysInfo.value || !readiness.value) return "";
return configToToml(sysInfo.value, readiness.value);
Expand Down Expand Up @@ -64,7 +86,10 @@ async function load() {
}
}

onMounted(load);
onMounted(() => {
load();
loadRouter();
});
</script>

<template>
Expand Down Expand Up @@ -175,6 +200,63 @@ onMounted(load);
</div>
</Card>
</template>

<!-- LLM Router -->
<Card v-if="routerLoading && !routerStatus" title="LLM Router">
<LoadingPanel message="Loading router status..." />
</Card>
<Card v-if="routerStatus || routerError" title="LLM Router">
<template v-if="routerStatus">
<div class="readiness-header">
<span :class="routerStatus.enabled ? 'status-ok' : 'status-warn'">
{{ routerStatus.enabled ? "Enabled" : "Disabled" }}
</span>
<span class="profile-badge">{{ routerStatus.default_profile }}</span>
</div>

<!-- Classifier Status -->
<div class="sub-heading">Classifier</div>
<div class="settings-grid">
<div><span class="label">Provider</span> <span class="mono val">{{ routerStatus.classifier.provider }}</span></div>
<div><span class="label">Model</span> <span class="mono val">{{ routerStatus.classifier.model }}</span></div>
<div><span class="label">Status</span>
<span :class="routerStatus.classifier.connected ? 'status-ok' : 'status-warn'">
{{ routerStatus.classifier.connected ? "Connected" : "Disconnected" }}
</span>
</div>
<div v-if="routerStatus.classifier.avg_latency_ms != null">
<span class="label">Avg Latency</span>
<span class="mono val">{{ routerStatus.classifier.avg_latency_ms }}ms</span>
</div>
</div>

<!-- Tier Assignments -->
<div class="sub-heading" style="margin-top: 0.8rem">Tier Assignments</div>
<div v-for="(models, tier) in routerStatus.tiers" :key="tier" class="tier-row">
<span class="tier-label">{{ tier }}</span>
<span class="mono val">{{ models.join(", ") || "\u2014" }}</span>
</div>

<!-- Recent Decisions (collapsible) -->
<div class="sub-heading clickable" style="margin-top: 0.8rem" @click="decisionsExpanded = !decisionsExpanded">
Recent Decisions {{ decisionsExpanded ? "\u25BE" : "\u25B8" }}
</div>
<div v-if="decisionsExpanded && routerDecisions.length > 0" class="decisions-log">
<div v-for="(d, idx) in routerDecisions" :key="idx" class="decision-row">
<span class="dim">{{ new Date(d.timestamp).toLocaleTimeString() }}</span>
<span class="tier-badge" :class="'tier-' + d.tier">{{ d.tier }}</span>
<span class="mono">{{ d.model }}</span>
<span class="dim">{{ d.latency_ms }}ms</span>
<span class="dim decision-snippet">{{ d.prompt_snippet }}</span>
</div>
</div>
<div v-if="decisionsExpanded && routerDecisions.length === 0" class="dim">
No routing decisions recorded yet.
</div>
</template>

<p v-if="routerError" class="error">{{ routerError }}</p>
</Card>
</template>

<!-- Edit mode: TOML config editor -->
Expand Down Expand Up @@ -297,4 +379,58 @@ button.secondary:hover { color: var(--text); border-color: var(--text-dim); }
button.secondary.danger { border-color: var(--red); color: var(--red); }
button.secondary.danger:hover { background: var(--red); color: #fff; }
button.secondary.danger:disabled { opacity: 0.5; cursor: not-allowed; }

/* Router card */
.profile-badge {
background: var(--accent);
color: #fff;
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.tier-row {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.2rem 0;
font-size: 0.82rem;
}
.tier-label {
min-width: 5rem;
color: var(--text-dim);
font-weight: 500;
text-transform: capitalize;
}
.clickable { cursor: pointer; user-select: none; }
.decisions-log {
max-height: 300px;
overflow-y: auto;
font-size: 0.78rem;
}
.decision-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.2rem 0;
border-bottom: 1px solid var(--border);
}
.decision-snippet {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tier-badge {
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.tier-simple { background: var(--green); color: #000; }
.tier-complex { background: var(--accent); color: #fff; }
.tier-reasoning { background: var(--red); color: #fff; }
.tier-free { background: var(--text-dim); color: #fff; }
</style>
Loading
Loading