Initial commit: Agentic OS troubleshooting platform

Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend +
LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini ->
DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials,
RBAC, audit log, project-memory + Obsidian integrations, and editable
troubleshooting decision rules tuned for the GeneseasX vessel stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 22:11:07 +03:00
commit 6185b9b85a
126 changed files with 14565 additions and 0 deletions

80
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,80 @@
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
const TOKEN_KEY = "agentic_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (typeof window === "undefined") return;
if (token) window.localStorage.setItem(TOKEN_KEY, token);
else window.localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export async function api<T = any>(
path: string,
options: RequestInit = {}
): Promise<T> {
const headers = new Headers(options.headers);
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (!headers.has("Content-Type") && options.body && !(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401 && typeof window !== "undefined") {
setToken(null);
if (!window.location.pathname.startsWith("/login")) {
window.location.href = "/login";
}
}
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail || detail;
} catch {}
throw new ApiError(res.status, typeof detail === "string" ? detail : JSON.stringify(detail));
}
if (res.status === 204) return undefined as T;
return res.json();
}
export async function login(email: string, password: string) {
const form = new URLSearchParams();
form.set("username", email);
form.set("password", password);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!res.ok) {
let detail = "Login failed";
try {
detail = (await res.json()).detail || detail;
} catch {}
throw new ApiError(res.status, detail);
}
return res.json() as Promise<{ access_token: string; role: string; email: string }>;
}
export function wsUrl(path: string): string {
const base = API_BASE.replace(/^http/, "ws");
const token = getToken();
const sep = path.includes("?") ? "&" : "?";
return `${base}${path}${sep}token=${encodeURIComponent(token || "")}`;
}
export const fetcher = (path: string) => api(path);

75
frontend/lib/auth.tsx Normal file
View File

@@ -0,0 +1,75 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { api, login as apiLogin, setToken, getToken } from "./api";
type Me = {
id: number;
email: string;
full_name: string | null;
role: "admin" | "support" | "readonly";
is_active: boolean;
};
type AuthCtx = {
user: Me | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
};
const Ctx = createContext<AuthCtx>({
user: null,
loading: true,
login: async () => {},
logout: () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const loadMe = useCallback(async () => {
if (!getToken()) {
setUser(null);
setLoading(false);
return;
}
try {
const me = await api<Me>("/api/auth/me");
setUser(me);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadMe();
}, [loadMe]);
const login = async (email: string, password: string) => {
const res = await apiLogin(email, password);
setToken(res.access_token);
await loadMe();
router.push("/");
};
const logout = () => {
setToken(null);
setUser(null);
router.push("/login");
};
return <Ctx.Provider value={{ user, loading, login, logout }}>{children}</Ctx.Provider>;
}
export const useAuth = () => useContext(Ctx);
export function roleAtLeast(role: string | undefined, min: "readonly" | "support" | "admin") {
const rank: Record<string, number> = { readonly: 0, support: 1, admin: 2 };
return (rank[role || "readonly"] ?? 0) >= rank[min];
}

View File

@@ -0,0 +1,93 @@
export const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"] as const;
export type TaskStatus =
| "queued"
| "running"
| "waiting_approval"
| "succeeded"
| "failed"
| "cancelled";
export type TaskEvent = {
id?: number;
kind: string;
message: string | null;
payload?: Record<string, any> | null;
ts?: string;
created_at?: string;
replay?: boolean;
};
export type TaskOverview = {
id: number;
title: string;
issue: string;
status: TaskStatus;
vessel_id: number | null;
vessel_name?: string | null;
summary?: string | null;
key_finding?: string | null;
devices_checked: string[];
resolved?: boolean | null;
memory_id?: string | null;
obsidian_path?: string | null;
obsidian_push_error?: string | null;
run_cost_usd?: number | null;
created_at: string;
updated_at: string;
};
export type TaskPreview = {
vessel_id: number;
vessel_name?: string | null;
devices: { catalog_key: string; name: string; label: string; checks: string[] }[];
rule_id?: string | null;
rule_name?: string | null;
severity?: string | null;
hints: string[];
warning?: string | null;
};
export function isActiveStatus(status: string) {
return ACTIVE_STATUSES.includes(status as (typeof ACTIVE_STATUSES)[number]);
}
export function taskPhaseLabel(status: string, events: TaskEvent[] = []) {
if (status === "queued") return "Waiting in queue";
if (status === "waiting_approval") return "Waiting for approval";
if (status === "succeeded") return "Report complete";
if (status === "failed") return "Failed";
if (status === "cancelled") return "Stopped";
const last = [...events].reverse().find((e) => e.kind !== "log");
if (!last) return "Starting";
if (last.kind === "context") return "Gathering context";
if (last.kind === "triage" || last.kind === "rule") return "Planning checks";
if (last.kind === "connect") return "Connecting to devices";
if (last.kind === "tool_start" || last.kind === "tool_call" || last.kind === "diagnose") {
const device = last.payload?.device;
const tool = humanToolName(String(last.payload?.tool || ""));
return [device, tool].filter(Boolean).join(": ") || "Running diagnostics";
}
if (last.kind === "reason") return "Analyzing findings";
if (last.kind === "report") return "Writing report";
return last.message || "Running";
}
export function humanToolName(tool: string) {
const known: Record<string, string> = {
pfsense_get_system_status: "pfSense system status",
pfsense_list_interfaces: "pfSense interfaces",
pfsense_list_gateways: "pfSense gateways",
pfsense_get_gateway_status: "pfSense gateway status",
pfsense_list_firewall_rules: "pfSense firewall rules",
pfsense_list_port_forwards: "pfSense port forwards",
pfsense_list_outbound_nat_mappings: "pfSense outbound NAT",
proxmox_list_nodes: "Proxmox nodes",
proxmox_list_qemu: "Proxmox VMs",
proxmox_list_lxc: "Proxmox LXCs",
proxmox_get_qemu_status: "VM status",
proxmox_get_qemu_config: "VM config",
ssh_run: "SSH command",
};
return known[tool] || tool.replace(/_/g, " ");
}