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:
80
frontend/lib/api.ts
Normal file
80
frontend/lib/api.ts
Normal 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
75
frontend/lib/auth.tsx
Normal 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];
|
||||
}
|
||||
93
frontend/lib/task-types.ts
Normal file
93
frontend/lib/task-types.ts
Normal 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, " ");
|
||||
}
|
||||
Reference in New Issue
Block a user