mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 08:57:26 +02:00
init
This commit is contained in:
83
front/src/components/CheatsheetList.svelte
Normal file
83
front/src/components/CheatsheetList.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
interface Sheet {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
let { sheets }: { sheets: Sheet[] } = $props();
|
||||
|
||||
let search = $state("");
|
||||
let activeTag: string | null = $state(
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("tag")
|
||||
: null
|
||||
);
|
||||
|
||||
const allTags = [...new Set(sheets.flatMap((s) => s.tags ?? []))].sort();
|
||||
|
||||
const filtered = $derived(
|
||||
sheets.filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch =
|
||||
!q ||
|
||||
s.title.toLowerCase().includes(q) ||
|
||||
(s.description?.toLowerCase().includes(q) ?? false);
|
||||
const matchTag = !activeTag || (s.tags?.includes(activeTag) ?? false);
|
||||
return matchSearch && matchTag;
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search cheatsheets..."
|
||||
bind:value={search}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
|
||||
{#if allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each allTags as tag}
|
||||
<button
|
||||
class="badge badge-md cursor-pointer transition-colors {activeTag === tag
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost hover:badge-outline'}"
|
||||
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-base-content/40 text-sm py-6 text-center">No results.</p>
|
||||
{:else}
|
||||
{#each filtered as sheet}
|
||||
<a
|
||||
href={`/cheatsheets/${sheet.id}`}
|
||||
class="card bg-base-200 hover:bg-base-300 transition-colors p-4 flex flex-row items-center gap-4"
|
||||
>
|
||||
<div class="size-2 rounded-full bg-primary shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm">{sheet.title}</div>
|
||||
{#if sheet.description}
|
||||
<div class="text-base-content/50 text-xs mt-0.5">{sheet.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sheet.tags && sheet.tags.length > 0}
|
||||
<div class="flex gap-1 shrink-0">
|
||||
{#each sheet.tags as tag}
|
||||
<span class="badge badge-xs badge-ghost">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
24
front/src/components/DemoBanner.svelte
Normal file
24
front/src/components/DemoBanner.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { FlaskConical } from "@lucide/svelte";
|
||||
|
||||
let demo = $state(false);
|
||||
|
||||
async function checkDemo() {
|
||||
try {
|
||||
const res = await fetch("/api/config");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
demo = data.demo === true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
checkDemo();
|
||||
</script>
|
||||
|
||||
{#if demo}
|
||||
<div class="w-full bg-warning/15 border-b border-warning/30 py-1.5 px-4 flex items-center justify-center gap-2 text-xs text-warning">
|
||||
<FlaskConical size={13} class="shrink-0" />
|
||||
<span>Demo mode — searches and configuration changes are disabled</span>
|
||||
</div>
|
||||
{/if}
|
||||
98
front/src/components/HomePage.svelte
Normal file
98
front/src/components/HomePage.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import { RotateCw, AlertTriangle } from "@lucide/svelte";
|
||||
import SearchBar from "./SearchBar.svelte";
|
||||
import SearchList from "./SearchList.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let searches = $state([]);
|
||||
let loadError = $state("");
|
||||
let redirecting = $state(false);
|
||||
let redirectTarget = $state("");
|
||||
let demo = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
loadSearches();
|
||||
fetch("/api/config")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => { if (d) demo = d.demo === true; })
|
||||
.catch(() => {});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const target = params.get("target");
|
||||
const type = params.get("type");
|
||||
if (target && type) {
|
||||
// Clean URL before launching so a refresh doesn't re-trigger
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
await handleSearch(target, type, params.get("profile") || "default");
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSearches() {
|
||||
try {
|
||||
const res = await fetch("/api/searches");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
searches = (data ?? []).sort(
|
||||
(a, b) => new Date(b.started_at) - new Date(a.started_at)
|
||||
);
|
||||
} catch (e) {
|
||||
loadError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(target, inputType, profile) {
|
||||
redirectTarget = target;
|
||||
redirecting = true;
|
||||
try {
|
||||
const res = await fetch("/api/searches", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target, input_type: inputType, profile: profile || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const s = await res.json();
|
||||
window.location.href = `/search/${s.id}`;
|
||||
} catch (e) {
|
||||
redirecting = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await fetch(`/api/searches/${id}`, { method: "DELETE" });
|
||||
searches = searches.filter((s) => s.id !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
|
||||
<div class="card bg-base-200 shadow p-6">
|
||||
{#if redirecting}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-4">
|
||||
<span class="loading loading-dots loading-md text-primary"></span>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<SearchBar onSearch={handleSearch} {demo} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Recent searches</h2>
|
||||
<button class="btn btn-ghost btn-xs" onclick={loadSearches}><RotateCw class="size-3" /> refresh</button>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<div class="alert alert-error text-sm gap-2"><AlertTriangle size={15} class="shrink-0" />{loadError}</div>
|
||||
{:else}
|
||||
<SearchList {searches} onDelete={handleDelete} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
136
front/src/components/Nav.svelte
Normal file
136
front/src/components/Nav.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
Hammer,
|
||||
SlidersHorizontal,
|
||||
GitBranch,
|
||||
User,
|
||||
BookOpen,
|
||||
Bug,
|
||||
ClipboardList,
|
||||
} from "@lucide/svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
action,
|
||||
}: {
|
||||
title?: string;
|
||||
action?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Search", href: "/", icon: Search },
|
||||
{ label: "Tools", href: "/tools", icon: Hammer },
|
||||
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
|
||||
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
|
||||
{
|
||||
label: "More",
|
||||
children: [
|
||||
{ label: "How it works", href: "/help", icon: BookOpen },
|
||||
{
|
||||
label: "Source code",
|
||||
href: "https://github.com/anotherhadi/iknowyou",
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
label: "Report a Bug",
|
||||
href: "https://github.com/anotherhadi/iknowyou/issues",
|
||||
icon: Bug,
|
||||
},
|
||||
{ label: "About me", href: "https://hadi.icu", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200">
|
||||
<div class="navbar max-w-5xl m-auto">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<Menu size={20} />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="-1"
|
||||
class="menu menu-sm dropdown-content bg-base-300 rounded-box z-50 mt-3 w-52 p-2"
|
||||
>
|
||||
{#each navLinks as link}
|
||||
<li>
|
||||
{#if link.children}
|
||||
<span>{link.label}</span>
|
||||
<ul class="p-2">
|
||||
{#each link.children as sublink}
|
||||
<li>
|
||||
<a href={sublink.href} class="flex items-center gap-2">
|
||||
{#if sublink.icon}
|
||||
{@const Icon = sublink.icon}
|
||||
<Icon size={12} />
|
||||
{/if}
|
||||
{sublink.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<a href={link.href} class="flex items-center gap-2">
|
||||
{#if link.icon}
|
||||
{@const Icon = link.icon}
|
||||
<Icon size={12} />
|
||||
{/if}
|
||||
{link.label}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<a
|
||||
href="/"
|
||||
class="btn btn-ghost text-xl flex justify-center gap-2 items-center"
|
||||
>
|
||||
<img src="/logo.svg" class="m-auto h-6" alt="iky logo" />
|
||||
<img src="/logo-large.svg" class="m-auto h-6" alt="iky logo large" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
{#each navLinks as link}
|
||||
<li>
|
||||
{#if link.children}
|
||||
<details>
|
||||
<summary>{link.label}</summary>
|
||||
<ul class="p-2 bg-base-300 w-52 z-50">
|
||||
{#each link.children as sublink}
|
||||
<li>
|
||||
<a href={sublink.href} class="flex items-center gap-2">
|
||||
{#if sublink.icon}
|
||||
{@const Icon = sublink.icon}
|
||||
<Icon size={12} />
|
||||
{/if}
|
||||
{sublink.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<a href={link.href} class="flex items-center gap-2">
|
||||
{#if link.icon}
|
||||
{@const Icon = link.icon}
|
||||
<Icon size={14} />
|
||||
{/if}
|
||||
{link.label}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{@render action?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
571
front/src/components/ProfileSettings.svelte
Normal file
571
front/src/components/ProfileSettings.svelte
Normal file
@@ -0,0 +1,571 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Plus, Trash2, Save, ChevronRight, X, Lock, AlertTriangle } from "@lucide/svelte";
|
||||
import Select from "./comps/Select.svelte";
|
||||
import Badge from "./comps/Badge.svelte";
|
||||
import InfoTip from "./comps/InfoTip.svelte";
|
||||
|
||||
let tools = $state([]);
|
||||
let profiles = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
let configReadonly = $state(false);
|
||||
|
||||
let selectedProfile = $state(null);
|
||||
let profileDetail = $state(null);
|
||||
let profileLoading = $state(false);
|
||||
|
||||
let notesEdit = $state("");
|
||||
let enabledEdit = $state([]);
|
||||
let disabledEdit = $state([]);
|
||||
let rulesSaving = $state(false);
|
||||
let rulesMsg = $state(null);
|
||||
|
||||
let overrideEdits = $state({});
|
||||
let overrideSaving = $state({});
|
||||
let overrideMsg = $state({});
|
||||
|
||||
let showNewProfile = $state(false);
|
||||
let newName = $state("");
|
||||
let newProfileSaving = $state(false);
|
||||
let newProfileError = $state("");
|
||||
|
||||
let overrideToolNames = $derived(Object.keys(profileDetail?.tools ?? {}));
|
||||
let configurableTools = $derived(tools.filter((t) => t.config_fields?.length > 0));
|
||||
let availableForOverride = $derived(configurableTools.filter((t) => !overrideToolNames.includes(t.name)));
|
||||
let allToolNames = $derived(tools.map((t) => t.name));
|
||||
let isReadonly = $derived((profileDetail?.readonly ?? false) || configReadonly);
|
||||
|
||||
onMount(loadAll);
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [tr, pr, cr] = await Promise.all([
|
||||
fetch("/api/tools"),
|
||||
fetch("/api/config/profiles"),
|
||||
fetch("/api/config"),
|
||||
]);
|
||||
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
|
||||
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
|
||||
tools = await tr.json();
|
||||
profiles = await pr.json();
|
||||
if (cr.ok) {
|
||||
const cfg = await cr.json();
|
||||
configReadonly = cfg.readonly ?? false;
|
||||
}
|
||||
if (!selectedProfile && profiles.length > 0) {
|
||||
const def = profiles.find((p) => p.name === "default");
|
||||
await selectProfile(def ? "default" : profiles[0].name);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProfile(name) {
|
||||
selectedProfile = name;
|
||||
profileLoading = true;
|
||||
profileDetail = null;
|
||||
overrideEdits = {};
|
||||
overrideSaving = {};
|
||||
overrideMsg = {};
|
||||
rulesMsg = null;
|
||||
try {
|
||||
const res = await fetch(`/api/config/profiles/${encodeURIComponent(name)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
profileDetail = await res.json();
|
||||
notesEdit = profileDetail.notes ?? "";
|
||||
enabledEdit = [...(profileDetail.enabled ?? [])];
|
||||
disabledEdit = [...(profileDetail.disabled ?? [])];
|
||||
const nextEdits = {};
|
||||
for (const [toolName, toolConf] of Object.entries(profileDetail.tools ?? {})) {
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
if (!tool?.config_fields?.length) continue;
|
||||
nextEdits[toolName] = {};
|
||||
for (const f of tool.config_fields) {
|
||||
nextEdits[toolName][f.name] =
|
||||
toolConf?.[f.name] !== undefined ? toolConf[f.name] : (f.default ?? "");
|
||||
}
|
||||
}
|
||||
overrideEdits = nextEdits;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateNewName(name) {
|
||||
if (!name) return "Name is required";
|
||||
if (!/^[a-z0-9-]+$/.test(name)) return "Only lowercase letters (a-z), digits (0-9), and hyphens (-) are allowed";
|
||||
return "";
|
||||
}
|
||||
|
||||
async function createProfile() {
|
||||
const name = newName.trim();
|
||||
const nameError = validateNewName(name);
|
||||
if (nameError) { newProfileError = nameError; return; }
|
||||
newProfileSaving = true;
|
||||
newProfileError = "";
|
||||
try {
|
||||
const res = await fetch("/api/config/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||
showNewProfile = false;
|
||||
newName = "";
|
||||
await loadAll();
|
||||
await selectProfile(name);
|
||||
} catch (e) {
|
||||
newProfileError = e.message;
|
||||
} finally {
|
||||
newProfileSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProfile(name) {
|
||||
if (!confirm(`Delete profile "${name}"?`)) return;
|
||||
try {
|
||||
await fetch(`/api/config/profiles/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
if (selectedProfile === name) {
|
||||
selectedProfile = null;
|
||||
profileDetail = null;
|
||||
}
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRules() {
|
||||
rulesSaving = true;
|
||||
rulesMsg = null;
|
||||
try {
|
||||
const res = await fetch(`/api/config/profiles/${encodeURIComponent(selectedProfile)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: enabledEdit, disabled: disabledEdit, notes: notesEdit }),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||
rulesMsg = { ok: true, text: "Saved" };
|
||||
setTimeout(() => (rulesMsg = null), 3000);
|
||||
await selectProfile(selectedProfile);
|
||||
} catch (e) {
|
||||
rulesMsg = { ok: false, text: e.message };
|
||||
} finally {
|
||||
rulesSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOverride(toolName) {
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
for (const f of tool?.config_fields ?? []) {
|
||||
if (f.required) {
|
||||
const v = overrideEdits[toolName]?.[f.name];
|
||||
if (v === undefined || v === null || v === "") {
|
||||
flashOverride(toolName, { ok: false, text: `"${f.name}" is required` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overrideSaving = { ...overrideSaving, [toolName]: true };
|
||||
overrideMsg = { ...overrideMsg, [toolName]: null };
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/config/profiles/${encodeURIComponent(selectedProfile)}/tools/${encodeURIComponent(toolName)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(overrideEdits[toolName]),
|
||||
}
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||
flashOverride(toolName, { ok: true, text: "Saved" });
|
||||
} catch (e) {
|
||||
flashOverride(toolName, { ok: false, text: e.message });
|
||||
} finally {
|
||||
overrideSaving = { ...overrideSaving, [toolName]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOverride(toolName) {
|
||||
if (!confirm(`Remove "${toolName}" override from "${selectedProfile}"?`)) return;
|
||||
try {
|
||||
await fetch(
|
||||
`/api/config/profiles/${encodeURIComponent(selectedProfile)}/tools/${encodeURIComponent(toolName)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
await selectProfile(selectedProfile);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function addOverrideFor(toolName) {
|
||||
if (!toolName) return;
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
if (!tool) return;
|
||||
const toolEdits = {};
|
||||
for (const f of tool.config_fields ?? []) toolEdits[f.name] = f.default ?? "";
|
||||
overrideEdits = { ...overrideEdits, [toolName]: toolEdits };
|
||||
profileDetail = {
|
||||
...profileDetail,
|
||||
tools: { ...(profileDetail.tools ?? {}), [toolName]: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function flashOverride(toolName, val) {
|
||||
overrideMsg = { ...overrideMsg, [toolName]: val };
|
||||
setTimeout(() => {
|
||||
overrideMsg = { ...overrideMsg, [toolName]: null };
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
||||
{:else}
|
||||
<div class="flex flex-col md:flex-row gap-0 items-start">
|
||||
|
||||
<div class="w-full md:w-52 shrink-0 flex flex-col gap-1 border-b border-base-300 pb-4 mb-4 md:border-b-0 md:border-r md:pb-0 md:mb-0 md:pr-4 md:mr-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/50">Profiles</span>
|
||||
{#if !configReadonly}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => { showNewProfile = !showNewProfile; newName = ""; newProfileError = ""; }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showNewProfile && !configReadonly}
|
||||
<div class="flex flex-col gap-2 p-3 bg-base-300 rounded-box mb-1">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full {newProfileError && !/^[a-z0-9-]*$/.test(newName) ? 'input-error' : ''}"
|
||||
placeholder="profile-name"
|
||||
bind:value={newName}
|
||||
onkeydown={(e) => e.key === "Enter" && createProfile()}
|
||||
/>
|
||||
{#if newProfileError}
|
||||
<p class="text-xs text-error">{newProfileError}</p>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-xs w-full"
|
||||
onclick={createProfile}
|
||||
disabled={newProfileSaving || !newName.trim()}
|
||||
>
|
||||
{#if newProfileSaving}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
Create
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each profiles as p}
|
||||
<div class="flex items-center gap-1 group">
|
||||
<button
|
||||
class="flex-1 btn btn-sm {selectedProfile === p.name ? 'btn-primary' : 'btn-ghost'} justify-start gap-1 truncate"
|
||||
onclick={() => selectProfile(p.name)}
|
||||
>
|
||||
{#if selectedProfile === p.name}
|
||||
<ChevronRight size={14} class="shrink-0" />
|
||||
{/if}
|
||||
{#if p.readonly}
|
||||
<Lock size={10} class="shrink-0 opacity-50" />
|
||||
{/if}
|
||||
{p.name}
|
||||
</button>
|
||||
{#if !p.readonly}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onclick={() => deleteProfile(p.name)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if profiles.length === 0}
|
||||
<p class="text-base-content/40 text-xs text-center py-4">No profiles yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 pt-4 md:pt-0">
|
||||
{#if configReadonly}
|
||||
<div class="alert alert-warning mb-4 py-2 px-3 text-sm gap-2">
|
||||
<Lock size={14} class="shrink-0" />
|
||||
Config is managed externally and is read-only.
|
||||
</div>
|
||||
{/if}
|
||||
{#if !selectedProfile}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">Select a profile to view it.</p>
|
||||
{:else if profileLoading}
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
{:else if profileDetail}
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="font-bold text-lg flex items-center gap-2">
|
||||
{#if isReadonly}<Lock size={14} class="text-base-content/40" />{/if}
|
||||
{selectedProfile}
|
||||
</h2>
|
||||
{#if isReadonly}
|
||||
<Badge text="read-only" size="sm" />
|
||||
{/if}
|
||||
{#if profileDetail.active_tools?.length > 0}
|
||||
<span class="text-xs text-base-content/50">
|
||||
{profileDetail.active_tools.length} active tool{profileDetail.active_tools.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isReadonly}
|
||||
{#if profileDetail.notes}
|
||||
<p class="text-sm text-base-content/60 italic">{profileDetail.notes}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/50">Notes</span>
|
||||
<textarea
|
||||
class="textarea textarea-bordered text-sm resize-none"
|
||||
placeholder="Add a description for this profile..."
|
||||
rows="2"
|
||||
bind:value={notesEdit}
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card bg-base-200 shadow">
|
||||
<div class="card-body gap-4 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Rules</h3>
|
||||
{#if !isReadonly && rulesMsg}
|
||||
<span class="text-xs {rulesMsg.ok ? 'text-success' : 'text-error'}">{rulesMsg.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold">
|
||||
Enabled
|
||||
<InfoTip tooltip="Tools in the enabled list will be allowed for this profile. If the enabled list is empty, all tools will be enabled." />
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
||||
{#if isReadonly}
|
||||
{#each (profileDetail.enabled ?? []) as toolName}
|
||||
<span class="badge badge-outline gap-1">{toolName}</span>
|
||||
{/each}
|
||||
{#if (profileDetail.enabled ?? []).length === 0}
|
||||
<span class="text-xs text-base-content/40">All tools</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each enabledEdit as toolName}
|
||||
<span class="badge badge-outline gap-1">
|
||||
{toolName}
|
||||
<button onclick={() => (enabledEdit = enabledEdit.filter((x) => x !== toolName))}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<Select
|
||||
options={allToolNames.filter((n) => !enabledEdit.includes(n))}
|
||||
placeholder="add tool"
|
||||
size="xs"
|
||||
onselect={(val) => (enabledEdit = [...enabledEdit, val])}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold">
|
||||
Disabled
|
||||
<InfoTip tooltip="Tools in the disabled list will be blocked for this profile. Applied after enabled rules, so if a tool is in both lists, it will be disabled." />
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
||||
{#if isReadonly}
|
||||
{#each (profileDetail.disabled ?? []) as toolName}
|
||||
<span class="badge badge-error gap-1">{toolName}</span>
|
||||
{/each}
|
||||
{#if (profileDetail.disabled ?? []).length === 0}
|
||||
<span class="text-xs text-base-content/40">None</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each disabledEdit as toolName}
|
||||
<span class="badge badge-error gap-1">
|
||||
{toolName}
|
||||
<button onclick={() => (disabledEdit = disabledEdit.filter((x) => x !== toolName))}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<Select
|
||||
options={allToolNames.filter((n) => !disabledEdit.includes(n))}
|
||||
placeholder="add tool"
|
||||
size="xs"
|
||||
onselect={(val) => (disabledEdit = [...disabledEdit, val])}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !isReadonly}
|
||||
<button
|
||||
class="btn btn-primary btn-sm gap-1 self-start"
|
||||
onclick={saveRules}
|
||||
disabled={rulesSaving}
|
||||
>
|
||||
{#if rulesSaving}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !isReadonly}
|
||||
<div class="card bg-base-200 shadow">
|
||||
<div class="card-body gap-4 p-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Tool overrides</h3>
|
||||
{#if availableForOverride.length > 0}
|
||||
<Select
|
||||
options={availableForOverride.map((t) => t.name)}
|
||||
placeholder="add override"
|
||||
size="xs"
|
||||
onselect={(val) => addOverrideFor(val)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if overrideToolNames.length === 0}
|
||||
<p class="text-sm text-base-content/40">No overrides configured.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each overrideToolNames as toolName}
|
||||
{@const tool = tools.find((t) => t.name === toolName)}
|
||||
<div class="border border-base-300 rounded-box p-3 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-semibold text-sm">{toolName}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if overrideMsg[toolName]}
|
||||
<span class="text-xs {overrideMsg[toolName].ok ? 'text-success' : 'text-error'}">
|
||||
{overrideMsg[toolName].text}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => deleteOverride(toolName)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tool?.config_fields?.length && overrideEdits[toolName]}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
||||
{#each tool.config_fields as field}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs font-semibold">{field.name}</span>
|
||||
<span class="badge badge-ghost badge-xs">{field.type}</span>
|
||||
{#if field.required}
|
||||
<span class="badge badge-error badge-xs">required</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if field.type === "bool"}
|
||||
<label class="flex items-center gap-2 cursor-pointer mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm toggle-primary"
|
||||
bind:checked={overrideEdits[toolName][field.name]}
|
||||
/>
|
||||
<span class="text-xs text-base-content/50">
|
||||
{overrideEdits[toolName][field.name] ? "enabled" : "disabled"}
|
||||
</span>
|
||||
</label>
|
||||
{:else if field.type === "int"}
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
class="input input-bordered input-sm font-mono"
|
||||
bind:value={overrideEdits[toolName][field.name]}
|
||||
/>
|
||||
{:else if field.type === "float"}
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
class="input input-bordered input-sm font-mono"
|
||||
bind:value={overrideEdits[toolName][field.name]}
|
||||
/>
|
||||
{:else if field.type === "enum"}
|
||||
<select
|
||||
class="select select-bordered select-sm font-mono"
|
||||
bind:value={overrideEdits[toolName][field.name]}
|
||||
>
|
||||
{#each field.options as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm font-mono"
|
||||
bind:value={overrideEdits[toolName][field.name]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm gap-1 self-start"
|
||||
onclick={() => saveOverride(toolName)}
|
||||
disabled={overrideSaving[toolName]}
|
||||
>
|
||||
{#if overrideSaving[toolName]}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-xs text-base-content/40">This tool has no configurable fields.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
232
front/src/components/SearchBar.svelte
Normal file
232
front/src/components/SearchBar.svelte
Normal file
@@ -0,0 +1,232 @@
|
||||
<script>
|
||||
import { Search, AlertTriangle } from "@lucide/svelte";
|
||||
import Select from "./comps/Select.svelte";
|
||||
import { INPUT_TYPES } from "@src/lib/vars";
|
||||
|
||||
let { onSearch = async () => {}, demo = false } = $props();
|
||||
|
||||
const DETECTORS = {
|
||||
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
||||
phone: (_raw, v) => /^\+\d{1,4} \d{4,}$/.test(v),
|
||||
ip: (_raw, v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v),
|
||||
domain: (raw, v) => /^https?:\/\//.test(raw) || /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v),
|
||||
name: (_raw, v) => /^[a-zA-ZÀ-ÿ'-]+(?: [a-zA-ZÀ-ÿ'-]+){1,2}$/.test(v),
|
||||
};
|
||||
|
||||
const VALIDATORS = {
|
||||
email: { test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), msg: "Invalid email address" },
|
||||
username: { test: (v) => /^[a-zA-Z0-9._-]+$/.test(v), msg: "Username may only contain a-z, 0-9, . - _" },
|
||||
phone: { test: (v) => /^\+\d{1,4} \d{4,}$/.test(v), msg: "Format: +INDICATIF NUMERO (ex: +33 0612345678)" },
|
||||
ip: { test: (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v), msg: "Invalid IP address" },
|
||||
domain: { test: (v) => /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), msg: "Invalid domain name" },
|
||||
};
|
||||
|
||||
let target = $state("");
|
||||
let inputType = $state("email");
|
||||
let profile = $state("default");
|
||||
let profiles = $state([]);
|
||||
let loading = $state(false);
|
||||
let error = $state("");
|
||||
let validationError = $state("");
|
||||
// null = auto-switch free; "TYPE" = auto-switched from TYPE (show revert); "__locked__" = user overrode, no auto-switch
|
||||
let prevType = $state(null);
|
||||
|
||||
let showRevert = $derived(prevType !== null && prevType !== "__locked__");
|
||||
let profileOptions = $derived(profiles.map((p) => p.name));
|
||||
|
||||
let strippedTarget = $derived.by(() => {
|
||||
let v = target.trim();
|
||||
v = v.replace(/^https?:\/\//, "");
|
||||
if (v.startsWith("@")) v = v.slice(1);
|
||||
return v;
|
||||
});
|
||||
|
||||
let detectedType = $derived.by(() => {
|
||||
const raw = target.trim();
|
||||
const v = strippedTarget;
|
||||
if (!v && !raw) return null;
|
||||
if (raw.startsWith("@")) return "username";
|
||||
for (const [type, fn] of Object.entries(DETECTORS)) {
|
||||
if (fn(raw, v)) return type;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
async function loadProfiles() {
|
||||
try {
|
||||
const res = await fetch("/api/config/profiles");
|
||||
if (res.ok) profiles = await res.json();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
|
||||
function sanitize(s) {
|
||||
return s.replace(/[<>"'`&]/g, "").trim();
|
||||
}
|
||||
|
||||
function validate(val, type) {
|
||||
const v = VALIDATORS[type];
|
||||
if (!v) return "";
|
||||
return v.test(val) ? "" : v.msg;
|
||||
}
|
||||
|
||||
function onTargetInput() {
|
||||
if (!strippedTarget) prevType = null; // reset when field is cleared
|
||||
|
||||
if (validationError) validationError = validate(strippedTarget, inputType);
|
||||
|
||||
if (prevType === null && detectedType && detectedType !== inputType) {
|
||||
prevType = inputType;
|
||||
inputType = detectedType;
|
||||
}
|
||||
}
|
||||
|
||||
function revert() {
|
||||
inputType = prevType;
|
||||
prevType = "__locked__";
|
||||
validationError = "";
|
||||
}
|
||||
|
||||
function onSelectType(v) {
|
||||
inputType = v;
|
||||
prevType = "__locked__";
|
||||
validationError = "";
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (demo) return;
|
||||
const clean = sanitize(strippedTarget);
|
||||
if (!clean) return;
|
||||
validationError = validate(clean, inputType);
|
||||
if (validationError) return;
|
||||
error = "";
|
||||
loading = true;
|
||||
try {
|
||||
await onSearch(clean, inputType, profile);
|
||||
target = "";
|
||||
prevType = null;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2 gap-2"><AlertTriangle size={15} class="shrink-0" />{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showRevert}
|
||||
<div class="flex items-center gap-2 px-1 text-xs text-base-content/50">
|
||||
<span>Switched to <span class="text-base-content/70 font-medium">{inputType}</span></span>
|
||||
<button
|
||||
class="badge badge-ghost badge-sm hover:badge-primary transition-colors cursor-pointer"
|
||||
onclick={revert}
|
||||
>
|
||||
← {prevType}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div class="flex flex-col gap-2 sm:hidden">
|
||||
<div class="flex items-center rounded-xl border border-base-content/15 bg-base-300
|
||||
focus-within:border-primary/40 transition-colors">
|
||||
<input
|
||||
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm
|
||||
placeholder:text-base-content/30 min-w-0"
|
||||
placeholder={demo ? "Search disabled in demo mode" : "Enter target..."}
|
||||
bind:value={target}
|
||||
oninput={onTargetInput}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
disabled={demo}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-base-content/25 pl-1">type</span>
|
||||
<Select
|
||||
options={INPUT_TYPES}
|
||||
selected={inputType}
|
||||
size="xs"
|
||||
onselect={onSelectType}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-base-content/25">profile</span>
|
||||
<Select
|
||||
options={profileOptions}
|
||||
selected={profile}
|
||||
size="xs"
|
||||
onselect={(v) => { profile = v; }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm flex-1 gap-1"
|
||||
onclick={submit}
|
||||
disabled={demo || loading || !target.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Search size={14} />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
class="hidden sm:flex items-center rounded-xl border border-base-content/15 bg-base-300
|
||||
focus-within:border-primary/40 transition-colors"
|
||||
>
|
||||
<div class="border-r border-base-content/10 flex items-center gap-1 pl-3">
|
||||
<span class="text-xs text-base-content/25 shrink-0">type</span>
|
||||
<Select
|
||||
options={INPUT_TYPES}
|
||||
selected={inputType}
|
||||
onselect={onSelectType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm
|
||||
placeholder:text-base-content/30 min-w-0"
|
||||
placeholder={demo ? "Search disabled in demo mode" : "Enter target..."}
|
||||
bind:value={target}
|
||||
oninput={onTargetInput}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
disabled={demo}
|
||||
/>
|
||||
|
||||
<div class="border-l border-base-content/10 flex items-center gap-1 pr-1 pl-3">
|
||||
<span class="text-xs text-base-content/25 shrink-0">profile</span>
|
||||
<Select
|
||||
options={profileOptions}
|
||||
selected={profile}
|
||||
onselect={(v) => { profile = v; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary btn-sm m-1 rounded-lg gap-1 shrink-0"
|
||||
onclick={submit}
|
||||
disabled={demo || loading || !target.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Search size={14} />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if validationError}
|
||||
<p class="text-xs text-error pl-1">{validationError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
349
front/src/components/SearchDetail.svelte
Normal file
349
front/src/components/SearchDetail.svelte
Normal file
@@ -0,0 +1,349 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { RefreshCw, ChevronRight, Check, X, AlertTriangle } from "@lucide/svelte";
|
||||
import TtyOutput from "@src/components/comps/TtyOutput.svelte";
|
||||
|
||||
let { id = null } = $props();
|
||||
|
||||
let demo = $state(false);
|
||||
async function checkDemo() {
|
||||
try {
|
||||
const res = await fetch("/api/config");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
demo = data.demo === true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
checkDemo();
|
||||
|
||||
let resolvedId = $state("");
|
||||
|
||||
let search = $state(null);
|
||||
let error = $state("");
|
||||
let pollTimeout = null;
|
||||
let pollDelay = $state(800);
|
||||
const POLL_MIN = 800;
|
||||
const POLL_MAX = 5000;
|
||||
|
||||
let grouped = $derived(groupByTool(search?.events ?? []));
|
||||
let toolProgress = $derived(computeProgress(search?.planned_tools ?? [], grouped));
|
||||
let totalResults = $derived(
|
||||
(search?.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)
|
||||
);
|
||||
|
||||
let sortedEntries = $derived((() => {
|
||||
const entries = Object.entries(grouped);
|
||||
const hasResults = ([toolName, d]) => {
|
||||
const count = search?.planned_tools?.find(t => t.name === toolName)?.result_count ?? null;
|
||||
return d.done && (count !== null ? count > 0 : d.output.length > 0);
|
||||
};
|
||||
const withResults = entries.filter(([n, d]) => hasResults([n, d]));
|
||||
const running = entries.filter(([_, d]) => !d.done);
|
||||
const noResults = entries.filter(([n, d]) => d.done && !hasResults([n, d]));
|
||||
return { withResults, running, noResults };
|
||||
})());
|
||||
|
||||
function groupByTool(events) {
|
||||
const map = {};
|
||||
for (const e of events) {
|
||||
if (!map[e.tool]) map[e.tool] = { errors: [], output: "", done: false };
|
||||
if (e.type === "output") map[e.tool].output += e.payload;
|
||||
else if (e.type === "error") map[e.tool].errors.push(e.payload);
|
||||
else if (e.type === "done") map[e.tool].done = true;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function computeProgress(planned, grouped) {
|
||||
const skipped = planned.filter((t) => t.skipped);
|
||||
const active = planned.filter((t) => !t.skipped);
|
||||
const errored = active.filter((t) => {
|
||||
const d = grouped[t.name];
|
||||
return d?.done && d.errors.length > 0 && d.output.length === 0;
|
||||
});
|
||||
const done = active.filter((t) => grouped[t.name]?.done);
|
||||
const running = active.filter((t) => !grouped[t.name]?.done);
|
||||
const skippedTotal = skipped.length + errored.length;
|
||||
return { skipped, active, done, running, errored, skippedTotal };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch(`/api/searches/${resolvedId}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
search = await res.json();
|
||||
if (search.status !== "running") stopPolling();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
pollTimeout = setTimeout(async () => {
|
||||
await refresh();
|
||||
if (search?.status === "running") {
|
||||
pollDelay = Math.min(pollDelay * 2, POLL_MAX);
|
||||
scheduleNext();
|
||||
}
|
||||
}, pollDelay);
|
||||
}
|
||||
|
||||
function startPolling() { stopPolling(); pollDelay = POLL_MIN; scheduleNext(); }
|
||||
function stopPolling() { if (pollTimeout) { clearTimeout(pollTimeout); pollTimeout = null; } }
|
||||
|
||||
onMount(async () => {
|
||||
resolvedId = id ?? window.location.pathname.replace(/^\/search\//, "").replace(/\/$/, "");
|
||||
await refresh();
|
||||
if (search?.status === "running") startPolling();
|
||||
});
|
||||
onDestroy(stopPolling);
|
||||
|
||||
let toast = $state(null); // { msg, type }
|
||||
|
||||
async function cancel() {
|
||||
stopPolling();
|
||||
await fetch(`/api/searches/${resolvedId}`, { method: "DELETE" });
|
||||
toast = { msg: "Search cancelled.", type: "alert-warning" };
|
||||
setTimeout(() => { window.location.href = "/"; }, 2000);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const STATUS_BADGE = {
|
||||
running: "badge-warning",
|
||||
done: "badge-success",
|
||||
cancelled: "badge-error",
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
||||
|
||||
{:else if !search}
|
||||
<div class="flex justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1 flex-wrap">
|
||||
<h1 class="font-mono text-xl sm:text-2xl font-bold truncate">{search.target}</h1>
|
||||
<span class="badge {STATUS_BADGE[search.status] ?? 'badge-ghost'} shrink-0">
|
||||
{#if search.status === "running"}
|
||||
<span class="loading loading-ring loading-xs mr-1"></span>
|
||||
{/if}
|
||||
{search.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap text-sm text-base-content/50">
|
||||
<span>{search.input_type}</span>
|
||||
{#if search.profile}
|
||||
<span class="badge badge-outline badge-sm font-semibold">{search.profile}</span>
|
||||
{/if}
|
||||
<span>· started {fmtDate(search.started_at)}</span>
|
||||
{#if search.status !== "running" && totalResults > 0}
|
||||
<span>· <span class="text-base-content/70 font-medium">{totalResults} result{totalResults !== 1 ? "s" : ""}</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button class="btn btn-sm btn-ghost gap-1" onclick={refresh}>
|
||||
<RefreshCw size={14} /> Refresh
|
||||
</button>
|
||||
{#if search.status === "running"}
|
||||
<button class="btn btn-sm btn-error btn-outline gap-1" onclick={cancel}>
|
||||
<X size={14} /> Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if search.planned_tools?.length > 0}
|
||||
<div class="card bg-base-200 shadow mb-6">
|
||||
<div class="card-body p-4 gap-3">
|
||||
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Tools</h2>
|
||||
<div class="flex gap-3 text-sm">
|
||||
<span class="text-success font-mono">
|
||||
{toolProgress.done.length}/{toolProgress.active.length} done
|
||||
</span>
|
||||
{#if toolProgress.skippedTotal > 0}
|
||||
<span class="text-warning font-mono">
|
||||
{toolProgress.skippedTotal} skipped
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-base-300 rounded-full overflow-hidden flex relative">
|
||||
<div class="h-full bg-success transition-all duration-500" style="width:{toolProgress.active.length > 0 ? (toolProgress.done.length - toolProgress.errored.length) / toolProgress.active.length * 100 : 0}%"></div>
|
||||
<div class="h-full bg-warning/70 transition-all duration-500" style="width:{toolProgress.active.length > 0 ? toolProgress.errored.length / toolProgress.active.length * 100 : 0}%"></div>
|
||||
{#if search.status === "running"}
|
||||
<div class="shimmer absolute inset-0 pointer-events-none"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary
|
||||
class="flex items-center gap-1 cursor-pointer text-xs text-base-content/40
|
||||
hover:text-base-content/70 transition-colors list-none select-none w-fit"
|
||||
>
|
||||
<ChevronRight size={12} class="transition-transform duration-200 group-open:rotate-90" />
|
||||
Show tools
|
||||
</summary>
|
||||
<div class="flex flex-wrap gap-2 pt-3">
|
||||
{#each search.planned_tools as t}
|
||||
{@const d = grouped[t.name]}
|
||||
{@const isErrored = d?.done && d.errors.length > 0 && d.output.length === 0}
|
||||
{#if t.skipped}
|
||||
<div class="tooltip" data-tip={t.reason}>
|
||||
<span class="badge badge-warning badge-sm font">{t.name}</span>
|
||||
</div>
|
||||
{:else if isErrored}
|
||||
<div class="tooltip" data-tip={d.errors[0] ?? "error"}>
|
||||
<span class="badge badge-warning badge-sm">{t.name}</span>
|
||||
</div>
|
||||
{:else if d?.done}
|
||||
<a href="/tools/{t.name}" class="badge badge-success badge-sm hover:badge-outline transition-all gap-1">
|
||||
<Check size={10} />{t.name}
|
||||
</a>
|
||||
{:else if search.status === "running"}
|
||||
<a href="/tools/{t.name}" class="badge badge-ghost badge-sm hover:badge-outline transition-all">
|
||||
<span class="loading loading-ring loading-xs mr-1"></span>{t.name}
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/tools/{t.name}" class="badge badge-ghost badge-sm hover:badge-outline transition-all">
|
||||
{t.name}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{#if demo}
|
||||
<p class="text-xs text-base-content/40 italic">Results shown are not exhaustive — demo mode only displays a subset of what the tools can find.</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if Object.keys(grouped).length === 0 && search.status === "running"}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">Waiting for results...</p>
|
||||
{:else if Object.keys(grouped).length === 0}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">No results.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
{#each sortedEntries.withResults as [toolName, data]}
|
||||
{@render toolCard(toolName, data)}
|
||||
{/each}
|
||||
|
||||
{#each sortedEntries.running as [toolName, data]}
|
||||
{@render toolCard(toolName, data)}
|
||||
{/each}
|
||||
|
||||
{#if sortedEntries.noResults.length > 0}
|
||||
<details class="group">
|
||||
<summary
|
||||
class="flex items-center gap-2 cursor-pointer select-none list-none
|
||||
text-sm text-base-content/40 hover:text-base-content/60 transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} class="transition-transform duration-200 group-open:rotate-90" />
|
||||
No results
|
||||
<span class="font-mono text-xs">({sortedEntries.noResults.length})</span>
|
||||
</summary>
|
||||
<div class="flex flex-col gap-3 pt-3">
|
||||
{#each sortedEntries.noResults as [toolName, data]}
|
||||
{@render toolCard(toolName, data)}
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#snippet toolCard(toolName, data)}
|
||||
{@const toolStatus = search.planned_tools?.find((t) => t.name === toolName)}
|
||||
{@const resultCount = data.output.length > 0 ? (toolStatus?.result_count ?? null) : null}
|
||||
<div class="card bg-base-200 shadow">
|
||||
<details class="group" open>
|
||||
<summary class="card-body gap-3 p-4 cursor-pointer list-none">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<ChevronRight size={14} class="shrink-0 opacity-40 transition-transform duration-200 group-open:rotate-90" />
|
||||
<a href="/tools/{toolName}" class="font-bold hover:underline underline-offset-2" onclick={(e) => e.stopPropagation()}>
|
||||
{toolName}
|
||||
</a>
|
||||
{#if !data.done}
|
||||
<span class="badge badge-warning badge-sm">
|
||||
<span class="loading loading-ring loading-xs mr-1"></span>running
|
||||
</span>
|
||||
{:else if data.errors.length > 0 && data.output.length === 0}
|
||||
<span class="badge badge-error badge-sm">error</span>
|
||||
{:else}
|
||||
<span class="badge badge-success badge-sm">done</span>
|
||||
{/if}
|
||||
<span class="text-xs text-base-content/40 ml-auto">
|
||||
{#if resultCount === null}
|
||||
output
|
||||
{:else}
|
||||
{resultCount} result{resultCount !== 1 ? "s" : ""}
|
||||
{/if}
|
||||
{#if data.errors.length > 0}
|
||||
· <span class="text-error">{data.errors.length} error{data.errors.length !== 1 ? "s" : ""}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="px-4 pb-4 flex flex-col gap-3">
|
||||
{#each data.errors as err}
|
||||
<div class="alert alert-error py-2 text-sm gap-2"><AlertTriangle size={14} class="shrink-0" />{err}</div>
|
||||
{/each}
|
||||
|
||||
{#if data.output.length > 0}
|
||||
<TtyOutput output={data.output} />
|
||||
{:else if data.done && data.errors.length === 0}
|
||||
<p class="text-sm text-base-content/40">No results.</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if toast}
|
||||
<div class="toast toast-end toast-bottom z-50">
|
||||
<div class="alert {toast.type} shadow-lg">
|
||||
<span>{toast.msg}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.18) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
</style>
|
||||
71
front/src/components/SearchList.svelte
Normal file
71
front/src/components/SearchList.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import { INPUT_TYPE_ICON } from "@src/lib/vars";
|
||||
import { FileText, X } from "@lucide/svelte";
|
||||
|
||||
let { searches = [], onDelete = async () => {} } = $props();
|
||||
|
||||
const STATUS_BADGE = {
|
||||
running: "badge-warning",
|
||||
done: "badge-success",
|
||||
cancelled: "badge-error",
|
||||
};
|
||||
|
||||
|
||||
function fmtDate(iso) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if searches.length === 0}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">No searches yet. Run one above.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each searches as s (s.id)}
|
||||
<a
|
||||
href={`/search/${s.id}`}
|
||||
class="card bg-base-200 hover:bg-base-300 transition-colors shadow-sm cursor-pointer"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-4 py-3 px-4">
|
||||
<div class="text-base-content/40 w-6 flex items-center justify-center shrink-0">
|
||||
{#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon}
|
||||
<Icon size={16} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="font-mono font-semibold truncate">{s.target}</span>
|
||||
<div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50">
|
||||
<span>{s.input_type}</span>
|
||||
{#if s.profile}
|
||||
<span class="badge badge-outline badge-xs font-semibold">{s.profile}</span>
|
||||
{/if}
|
||||
<span>· {fmtDate(s.started_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if s.status !== "running"}
|
||||
{@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)}
|
||||
{#if total > 0}
|
||||
<span class="text-xs font-mono text-base-content/50 shrink-0">{total} result{total !== 1 ? "s" : ""}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<span class="badge {STATUS_BADGE[s.status] ?? 'badge-ghost'} badge-sm shrink-0">
|
||||
{#if s.status === "running"}
|
||||
<span class="loading loading-ring loading-xs mr-1"></span>
|
||||
{/if}
|
||||
{s.status}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error shrink-0"
|
||||
onclick={(e) => { e.preventDefault(); onDelete(s.id); }}
|
||||
title="Delete"
|
||||
><X size={14} /></button>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
265
front/src/components/ToolDetail.svelte
Normal file
265
front/src/components/ToolDetail.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Save, Trash2, AlertTriangle, Package } from "@lucide/svelte";
|
||||
import ToolIcon from "./comps/ToolIcon.svelte";
|
||||
|
||||
let { name = null } = $props();
|
||||
|
||||
let resolvedName = $state("");
|
||||
let tool = $state(null);
|
||||
let error = $state("");
|
||||
let edits = $state({});
|
||||
let saving = $state(false);
|
||||
let msg = $state(null);
|
||||
let hasGlobalConfig = $state(false);
|
||||
let configReadonly = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
resolvedName = name ?? window.location.pathname.replace(/^\/tools\//, "").replace(/\/$/, "");
|
||||
try {
|
||||
const [toolRes, cfgRes] = await Promise.all([
|
||||
fetch(`/api/tools/${encodeURIComponent(resolvedName)}`),
|
||||
fetch("/api/config"),
|
||||
]);
|
||||
if (!toolRes.ok) {
|
||||
const body = await toolRes.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${toolRes.status}`);
|
||||
}
|
||||
tool = await toolRes.json();
|
||||
const cfg = cfgRes.ok ? await cfgRes.json() : { tools: {} };
|
||||
configReadonly = cfg.readonly ?? false;
|
||||
const curMap = cfg.tools?.[resolvedName] ?? {};
|
||||
hasGlobalConfig = !!cfg.tools?.[resolvedName];
|
||||
|
||||
const next = {};
|
||||
for (const f of tool.config_fields ?? []) {
|
||||
const saved = curMap[f.name];
|
||||
next[f.name] = saved !== undefined && saved !== null
|
||||
? saved
|
||||
: (f.value !== undefined && f.value !== null ? f.value : (f.default ?? defaultForType(f.type)));
|
||||
}
|
||||
edits = next;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
function defaultForType(type) {
|
||||
if (type === "bool") return false;
|
||||
if (type === "int" || type === "float") return 0;
|
||||
return "";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
msg = null;
|
||||
try {
|
||||
const res = await fetch(`/api/config/tools/${encodeURIComponent(resolvedName)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||
hasGlobalConfig = true;
|
||||
msg = { ok: true, text: "Saved" };
|
||||
} catch (e) {
|
||||
msg = { ok: false, text: e.message };
|
||||
} finally {
|
||||
saving = false;
|
||||
setTimeout(() => (msg = null), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearConfig() {
|
||||
if (!confirm(`Clear global config for "${resolvedName}"?`)) return;
|
||||
try {
|
||||
await fetch(`/api/config/tools/${encodeURIComponent(resolvedName)}`, { method: "DELETE" });
|
||||
hasGlobalConfig = false;
|
||||
const next = {};
|
||||
for (const f of tool.config_fields ?? []) next[f.name] = f.default ?? defaultForType(f.type);
|
||||
edits = next;
|
||||
msg = { ok: true, text: "Cleared" };
|
||||
} catch (e) {
|
||||
msg = { ok: false, text: e.message };
|
||||
} finally {
|
||||
setTimeout(() => (msg = null), 3000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
||||
|
||||
{:else if !tool}
|
||||
<div class="flex justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6 max-w-2xl">
|
||||
|
||||
{#if tool.available === false}
|
||||
<div class="alert alert-error gap-3">
|
||||
<AlertTriangle size={18} class="shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm">Tool unavailable</p>
|
||||
{#if tool.unavailable_reason}
|
||||
<p class="text-sm opacity-80">{tool.unavailable_reason}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<ToolIcon iconName={tool.icon} size={32} />
|
||||
<div class="pl-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-1">{tool.name}</h1>
|
||||
{#if tool.description}
|
||||
<p class="text-base-content/60">{tool.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if tool.link}
|
||||
<a href={tool.link} target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-sm gap-1">
|
||||
↗ source
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body gap-3 p-4">
|
||||
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Accepted input types</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each tool.input_types as t}
|
||||
<span class="badge badge-outline border-base-content/20">{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tool.dependencies?.length > 0}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body gap-3 p-4">
|
||||
<h2 class="text-xs uppercase tracking-widest text-base-content/50 flex items-center gap-2">
|
||||
<Package size={13} /> External dependencies
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each tool.dependencies as dep}
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm bg-base-300 px-2 py-0.5 rounded">{dep}</span>
|
||||
<span class="text-xs text-base-content/40">must be in <code>$PATH</code></span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tool.config_fields?.length > 0}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body gap-4 p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-xs uppercase tracking-widest text-base-content/50">Global config</h2>
|
||||
{#if hasGlobalConfig}
|
||||
<span class="badge badge-outline badge-xs">configured</span>
|
||||
{/if}
|
||||
{#if configReadonly}
|
||||
<span class="badge badge-ghost badge-xs">read-only</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if msg}
|
||||
<span class="text-xs {msg.ok ? 'text-success' : 'text-error'}">{msg.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each tool.config_fields as field}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono font-semibold text-sm">{field.name}</span>
|
||||
<span class="badge badge-ghost badge-xs">{field.type}</span>
|
||||
{#if field.required}
|
||||
<span class="badge badge-error badge-xs">required</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if field.description}
|
||||
<p class="text-sm text-base-content/60">{field.description}</p>
|
||||
{/if}
|
||||
<div class="text-xs text-base-content/40 font-mono mb-1">
|
||||
default: {field.default ?? "-"}
|
||||
</div>
|
||||
|
||||
{#if field.type === "bool"}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm toggle-primary"
|
||||
bind:checked={edits[field.name]}
|
||||
disabled={configReadonly}
|
||||
/>
|
||||
<span class="text-sm text-base-content/50">
|
||||
{edits[field.name] ? "enabled" : "disabled"}
|
||||
</span>
|
||||
</label>
|
||||
{:else if field.type === "int"}
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
class="input input-bordered input-sm font-mono w-full max-w-48"
|
||||
bind:value={edits[field.name]}
|
||||
/>
|
||||
{:else if field.type === "float"}
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
class="input input-bordered input-sm font-mono w-full max-w-48"
|
||||
bind:value={edits[field.name]}
|
||||
/>
|
||||
{:else if field.type === "enum"}
|
||||
<select
|
||||
class="select select-bordered select-sm font-mono w-full max-w-xs"
|
||||
bind:value={edits[field.name]}
|
||||
>
|
||||
{#each field.options as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm font-mono w-full max-w-xs"
|
||||
bind:value={edits[field.name]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !configReadonly}
|
||||
<div class="flex gap-2 pt-1 flex-wrap">
|
||||
<button
|
||||
class="btn btn-primary btn-sm gap-1"
|
||||
onclick={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
{#if hasGlobalConfig}
|
||||
<button class="btn btn-ghost btn-sm gap-1 text-error" onclick={clearConfig}>
|
||||
<Trash2 size={14} /> Reset to defaults
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
346
front/src/components/ToolList.svelte
Normal file
346
front/src/components/ToolList.svelte
Normal file
@@ -0,0 +1,346 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { AlertTriangle } from "@lucide/svelte";
|
||||
import Select from "./comps/Select.svelte";
|
||||
import { INPUT_TYPES } from "@src/lib/vars";
|
||||
import ToolIcon from "./comps/ToolIcon.svelte";
|
||||
|
||||
let tools = $state([]);
|
||||
let config = $state({ tools: {}, profiles: {} });
|
||||
let profileSummaries = $state([]);
|
||||
let selectedProfile = $state("default");
|
||||
let profileDetail = $state(null);
|
||||
let loading = $state(true);
|
||||
let profileLoading = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
let selectedInputType = $state("all");
|
||||
|
||||
const inputTypeOptions = ["all", ...INPUT_TYPES];
|
||||
|
||||
let profileOptions = $derived(profileSummaries.map((p) => p.name));
|
||||
|
||||
let activeSet = $derived(
|
||||
profileDetail
|
||||
? new Set(profileDetail.active_tools ?? [])
|
||||
: new Set(tools.map((t) => t.name)),
|
||||
);
|
||||
|
||||
let globalToolConf = $derived(config.tools ?? {});
|
||||
let profileOverrides = $derived(profileDetail?.tools ?? {});
|
||||
|
||||
let toolsWithStatus = $derived(
|
||||
tools.map((tool) => {
|
||||
const isActive = activeSet.has(tool.name);
|
||||
const effective = {
|
||||
...(globalToolConf[tool.name] ?? {}),
|
||||
...(profileOverrides[tool.name] ?? {}),
|
||||
};
|
||||
const missingConfig = (tool.config_fields ?? []).some((f) => {
|
||||
if (!f.required) return false;
|
||||
const v = effective[f.name];
|
||||
return v === undefined || v === null || v === "";
|
||||
});
|
||||
const unavailable = tool.available === false;
|
||||
return { ...tool, isActive, missingConfig, unavailable };
|
||||
}),
|
||||
);
|
||||
|
||||
let visibleTools = $derived(
|
||||
selectedInputType === "all"
|
||||
? toolsWithStatus
|
||||
: toolsWithStatus.filter((t) =>
|
||||
t.input_types.includes(selectedInputType),
|
||||
),
|
||||
);
|
||||
|
||||
let active = $derived(
|
||||
visibleTools.filter(
|
||||
(t) => t.isActive && !t.missingConfig && !t.unavailable,
|
||||
),
|
||||
);
|
||||
let activeMissing = $derived(
|
||||
visibleTools.filter((t) => t.isActive && t.missingConfig && !t.unavailable),
|
||||
);
|
||||
let activeUnavail = $derived(
|
||||
visibleTools.filter((t) => t.isActive && t.unavailable),
|
||||
);
|
||||
let inactive = $derived(
|
||||
visibleTools.filter(
|
||||
(t) => !t.isActive && !t.missingConfig && !t.unavailable,
|
||||
),
|
||||
);
|
||||
let inactiveMissing = $derived(
|
||||
visibleTools.filter(
|
||||
(t) => !t.isActive && t.missingConfig && !t.unavailable,
|
||||
),
|
||||
);
|
||||
let inactiveUnavail = $derived(
|
||||
visibleTools.filter((t) => !t.isActive && t.unavailable),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [tr, cr, pr] = await Promise.all([
|
||||
fetch("/api/tools"),
|
||||
fetch("/api/config"),
|
||||
fetch("/api/config/profiles"),
|
||||
]);
|
||||
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
|
||||
if (!cr.ok) throw new Error(`HTTP ${cr.status}`);
|
||||
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
|
||||
tools = await tr.json();
|
||||
config = await cr.json();
|
||||
profileSummaries = await pr.json();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectProfile(name) {
|
||||
selectedProfile = name;
|
||||
profileLoading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/config/profiles/${encodeURIComponent(name)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
profileDetail = await res.json();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet toolCard(tool, missing)}
|
||||
<div
|
||||
class="card bg-base-200 group-hover:bg-base-300 transition-colors shadow-sm h-full
|
||||
{missing ? 'border border-warning/40' : ''}
|
||||
{tool.unavailable ? 'border border-error/40' : ''}"
|
||||
>
|
||||
<div class="card-body p-4 flex-row items-start gap-0">
|
||||
<div
|
||||
class="size-10 rounded-lg bg-base-300 group-hover:bg-base-200 transition-colors
|
||||
flex items-center justify-center shrink-0 mr-3 mt-0.5"
|
||||
>
|
||||
<ToolIcon iconName={tool.icon} size={20} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col min-w-0 flex-1 gap-1.5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-bold text-sm leading-tight">{tool.name}</span>
|
||||
{#if tool.unavailable}
|
||||
<span class="badge badge-error badge-xs gap-1">
|
||||
<AlertTriangle size={9} /> unavailable
|
||||
</span>
|
||||
{:else if missing}
|
||||
<span class="badge badge-warning badge-xs gap-1">
|
||||
<AlertTriangle size={9} /> config required
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if tool.unavailable && tool.unavailable_reason}
|
||||
<p class="text-xs text-error/70 leading-relaxed">
|
||||
{tool.unavailable_reason}
|
||||
</p>
|
||||
{:else if tool.description}
|
||||
<p class="text-xs text-base-content/50 line-clamp-2 leading-relaxed">
|
||||
{tool.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each tool.input_types as t}
|
||||
<span class="badge badge-xs badge-outline border-base-content/20">{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
|
||||
>Profile</span
|
||||
>
|
||||
<Select
|
||||
options={profileOptions}
|
||||
selected={selectedProfile}
|
||||
onselect={selectProfile}
|
||||
/>
|
||||
{#if profileLoading}
|
||||
<span class="loading loading-spinner loading-xs opacity-40"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
|
||||
>Input</span
|
||||
>
|
||||
<Select
|
||||
options={inputTypeOptions}
|
||||
selected={selectedInputType}
|
||||
onselect={(val) => (selectedInputType = val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tools.length === 0}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">
|
||||
No tools registered.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if active.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="size-1.5 rounded-full bg-success shrink-0"></span>
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/50"
|
||||
>Active</span
|
||||
>
|
||||
<span class="text-xs text-base-content/30">{active.length}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each active as tool}
|
||||
<a href="/tools/{tool.name}" class="group">
|
||||
{@render toolCard(tool, false)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if activeMissing.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="size-1.5 rounded-full bg-warning shrink-0"></span>
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/50"
|
||||
>Active - required config missing</span
|
||||
>
|
||||
<span class="text-xs text-base-content/30"
|
||||
>{activeMissing.length}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each activeMissing as tool}
|
||||
<a href="/tools/{tool.name}" class="group">
|
||||
{@render toolCard(tool, true)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if activeUnavail.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="size-1.5 rounded-full bg-error shrink-0"></span>
|
||||
<span class="text-xs uppercase tracking-widest text-error/70"
|
||||
>Active - unavailable</span
|
||||
>
|
||||
<span class="text-xs text-base-content/30"
|
||||
>{activeUnavail.length}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each activeUnavail as tool}
|
||||
<a href="/tools/{tool.name}" class="group">
|
||||
{@render toolCard(tool, false)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if active.length + activeMissing.length + activeUnavail.length > 0 && inactive.length + inactiveMissing.length + inactiveUnavail.length > 0}
|
||||
<div class="divider opacity-20 my-0"></div>
|
||||
{/if}
|
||||
|
||||
{#if inactive.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
|
||||
></span>
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/30"
|
||||
>Disabled</span
|
||||
>
|
||||
<span class="text-xs text-base-content/20">{inactive.length}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
|
||||
{#each inactive as tool}
|
||||
<a
|
||||
href="/tools/{tool.name}"
|
||||
class="group hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{@render toolCard(tool, false)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if inactiveMissing.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
|
||||
></span>
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/30"
|
||||
>Disabled - required config missing</span
|
||||
>
|
||||
<span class="text-xs text-base-content/20"
|
||||
>{inactiveMissing.length}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
|
||||
{#each inactiveMissing as tool}
|
||||
<a
|
||||
href="/tools/{tool.name}"
|
||||
class="group hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{@render toolCard(tool, true)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if inactiveUnavail.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
|
||||
></span>
|
||||
<span class="text-xs uppercase tracking-widest text-base-content/30"
|
||||
>Disabled - unavailable</span
|
||||
>
|
||||
<span class="text-xs text-base-content/20"
|
||||
>{inactiveUnavail.length}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
|
||||
{#each inactiveUnavail as tool}
|
||||
<a
|
||||
href="/tools/{tool.name}"
|
||||
class="group hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{@render toolCard(tool, false)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
29
front/src/components/comps/Badge.svelte
Normal file
29
front/src/components/comps/Badge.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
let { text, color = null, icon: IconComponent = null, size = "sm", loading = false } = $props();
|
||||
|
||||
const colorDefaults = {
|
||||
"done": "badge-success",
|
||||
"error": "badge-error",
|
||||
"unavailable": "badge-error",
|
||||
"running": "badge-warning",
|
||||
"cancelled": "badge-error",
|
||||
"required": "badge-error",
|
||||
"read-only": "badge-ghost",
|
||||
"active": "badge-success",
|
||||
"disabled": "badge-ghost",
|
||||
"configured": "badge-outline",
|
||||
"configurable": "badge-outline",
|
||||
"config required":"badge-warning",
|
||||
};
|
||||
|
||||
let cls = $derived(color ?? colorDefaults[text?.toLowerCase()] ?? "badge-ghost");
|
||||
</script>
|
||||
|
||||
<span class="badge badge-{size} {cls} gap-1">
|
||||
{#if loading}
|
||||
<span class="loading loading-ring loading-xs"></span>
|
||||
{:else if IconComponent}
|
||||
<IconComponent size={9} />
|
||||
{/if}
|
||||
{text}
|
||||
</span>
|
||||
13
front/src/components/comps/InfoTip.svelte
Normal file
13
front/src/components/comps/InfoTip.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { CircleQuestionMark } from "@lucide/svelte";
|
||||
|
||||
let {
|
||||
tooltip,
|
||||
}: {
|
||||
tooltip: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="tooltip" data-tip={tooltip}>
|
||||
<CircleQuestionMark class="size-3 text-base-content/40 align-middle"/>
|
||||
</div>
|
||||
84
front/src/components/comps/Select.svelte
Normal file
84
front/src/components/comps/Select.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script>
|
||||
import { ChevronDown } from "@lucide/svelte";
|
||||
|
||||
let {
|
||||
options = [],
|
||||
placeholder = "",
|
||||
selected = null,
|
||||
onselect,
|
||||
size = "sm", // "xs" | "sm" | ""
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state("");
|
||||
let container;
|
||||
let input = $state();
|
||||
|
||||
let filtered = $derived(options.filter((o) =>
|
||||
o.toLowerCase().includes(query.toLowerCase())
|
||||
));
|
||||
|
||||
function select(value) {
|
||||
onselect?.(value);
|
||||
open = false;
|
||||
query = "";
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
if (open) setTimeout(() => input?.focus(), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onmousedown={(e) => {
|
||||
if (container && !container.contains(e.target)) {
|
||||
open = false;
|
||||
query = "";
|
||||
}
|
||||
}} />
|
||||
|
||||
<div class="relative" bind:this={container}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-{size} gap-1 font-normal"
|
||||
onclick={toggle}
|
||||
>
|
||||
<ChevronDown size={11} />
|
||||
{selected ?? placeholder}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="absolute z-50 top-full left-0 mt-1 w-52 bg-base-300 rounded-box shadow-xl border border-base-content/10 flex flex-col"
|
||||
>
|
||||
<div class="p-2 border-b border-base-content/10">
|
||||
<input
|
||||
bind:this={input}
|
||||
type="text"
|
||||
class="input input-bordered input-xs w-full"
|
||||
placeholder="Search..."
|
||||
bind:value={query}
|
||||
onkeydown={(e) => { if (e.key === "Escape") { open = false; query = ""; } }}
|
||||
/>
|
||||
</div>
|
||||
<ul class="max-h-48 overflow-y-auto p-1">
|
||||
{#if filtered.length === 0}
|
||||
<li class="px-3 py-2 text-xs text-base-content/40 text-center">No results</li>
|
||||
{:else}
|
||||
{#each filtered as option}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-1.5 text-sm font-mono rounded-btn transition-colors
|
||||
{option === selected ? 'bg-primary/15 text-primary font-semibold' : 'hover:bg-base-content/10'}"
|
||||
onclick={() => select(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
29
front/src/components/comps/ToolIcon.svelte
Normal file
29
front/src/components/comps/ToolIcon.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
const { iconName = "", size=16 }: { iconName: string , size: number} = $props();
|
||||
|
||||
const genericFallbackUrl = "/Wrench.svg";
|
||||
</script>
|
||||
|
||||
{#if iconName}
|
||||
<img
|
||||
src="https://cdn.simpleicons.org/{iconName}"
|
||||
alt={iconName + " icon"}
|
||||
class="opacity-50"
|
||||
width={size}
|
||||
height={size}
|
||||
style="filter: brightness(0) invert(1);"
|
||||
onerror={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.src = genericFallbackUrl;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={genericFallbackUrl}
|
||||
alt={"Tool icon"}
|
||||
class="opacity-50"
|
||||
width={size}
|
||||
height={size}
|
||||
style="filter: brightness(0) invert(1);"
|
||||
/>
|
||||
{/if}
|
||||
63
front/src/components/comps/TtyOutput.svelte
Normal file
63
front/src/components/comps/TtyOutput.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
import { AnsiUp } from "ansi_up";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
let { output } = $props();
|
||||
|
||||
const au = new AnsiUp();
|
||||
au.use_classes = false;
|
||||
|
||||
const ansiRe = /\x1b\[[0-9;]*m/g;
|
||||
const urlRe = /https?:\/\/[^\s<>"']+/g;
|
||||
const tlds = "com|org|net|io|fr|de|uk|co|info|biz|eu|us|ca|au|ru|cn|jp|br|in|es|dev|app|me|tv|cc|ch|be|nl|se|no|dk|fi|pl|cz|at|hu|ro";
|
||||
const bareDomainRe = new RegExp(`(?<![a-zA-Z0-9@])[a-zA-Z0-9][a-zA-Z0-9\\-]*(?:\\.[a-zA-Z0-9][a-zA-Z0-9\\-]*)*\\.(?:${tlds})(?:/[^\\s<>"']*)?`, "g");
|
||||
|
||||
const makeLink = (href, text) =>
|
||||
`<a href="${href}" target="_blank" rel="noopener noreferrer" class="ansi-link">${text}</a>`;
|
||||
|
||||
function linkifyText(text) {
|
||||
// First pass: full URLs
|
||||
const afterUrls = text.replace(urlRe, (url) => makeLink(url, url));
|
||||
// Re-split to protect the newly created <a> tags, then linkify bare domains in remaining text
|
||||
return afterUrls.split(/(<[^>]+>)/).map((part) => {
|
||||
if (part.startsWith("<")) return part;
|
||||
return part.replace(bareDomainRe, (domain) => makeLink(`https://${domain}`, domain));
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function linkify(html) {
|
||||
return html.split(/(<[^>]+>)/).map((part) =>
|
||||
part.startsWith("<") ? part : linkifyText(part)
|
||||
).join("");
|
||||
}
|
||||
|
||||
let html = $derived((() => {
|
||||
const lines = output.split("\n");
|
||||
let start = 0;
|
||||
let end = lines.length - 1;
|
||||
while (start <= end && lines[start].replace(ansiRe, "").trim() === "") start++;
|
||||
while (end >= start && lines[end].replace(ansiRe, "").trim() === "") end--;
|
||||
return DOMPurify.sanitize(linkify(au.ansi_to_html(lines.slice(start, end + 1).join("\n"))), {
|
||||
ALLOWED_TAGS: ["span", "a", "b"],
|
||||
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
|
||||
});
|
||||
})());
|
||||
</script>
|
||||
|
||||
{#if html}
|
||||
<div class="ansi-output">{@html html}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.ansi-output {
|
||||
line-height: 1.35;
|
||||
}
|
||||
.ansi-output :global(.ansi-link) {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ansi-output :global(.ansi-link:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user