mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-05-20 09:12:34 +02:00
302166c87d
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
750 lines
31 KiB
Svelte
750 lines
31 KiB
Svelte
<script>
|
|
import { onMount } from "svelte";
|
|
import {
|
|
Plus, Trash2, Save, Shield, ChevronRight, X,
|
|
Lock, AlertTriangle, SlidersHorizontal,
|
|
} from "@lucide/svelte";
|
|
import Select from "./comps/Select.svelte";
|
|
import Badge from "./comps/Badge.svelte";
|
|
import InfoTip from "./comps/InfoTip.svelte";
|
|
|
|
// ── Shared ────────────────────────────────────────────────────────────────
|
|
let loading = $state(true);
|
|
let error = $state("");
|
|
let configReadonly = $state(false);
|
|
let selectedView = $state("proxies"); // "proxies" | profile name
|
|
|
|
// ── Proxy state ───────────────────────────────────────────────────────────
|
|
let proxies = $state([]);
|
|
let proxychainsAvailable = $state(true);
|
|
let proxySaving = $state(false);
|
|
let proxyMsg = $state(null);
|
|
let newUrl = $state("");
|
|
let newUrlError = $state("");
|
|
|
|
// ── Profile state ─────────────────────────────────────────────────────────
|
|
let tools = $state([]);
|
|
let profiles = $state([]);
|
|
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;
|
|
proxies = cfg.proxies ?? [];
|
|
proxychainsAvailable = cfg.proxychains_available ?? true;
|
|
}
|
|
} catch (e) {
|
|
error = e.message;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// ── Proxy helpers ─────────────────────────────────────────────────────────
|
|
function validateProxyUrl(url) {
|
|
if (!url) return "URL is required";
|
|
try {
|
|
const u = new URL(url);
|
|
if (!["socks4:", "socks5:", "http:"].includes(u.protocol))
|
|
return "Protocol must be socks4, socks5, or http";
|
|
if (!u.hostname) return "Missing hostname";
|
|
} catch { return "Invalid URL format"; }
|
|
return "";
|
|
}
|
|
|
|
function addProxy() {
|
|
const url = newUrl.trim();
|
|
const err = validateProxyUrl(url);
|
|
if (err) { newUrlError = err; return; }
|
|
if (proxies.some((p) => p.url === url)) { newUrlError = "Already in list"; return; }
|
|
proxies = [...proxies, { url }];
|
|
newUrl = "";
|
|
newUrlError = "";
|
|
}
|
|
|
|
function removeProxy(i) { proxies = proxies.filter((_, j) => j !== i); }
|
|
|
|
async function saveProxies() {
|
|
proxySaving = true;
|
|
proxyMsg = null;
|
|
try {
|
|
const res = await fetch("/api/config/proxies", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(proxies),
|
|
});
|
|
if (!res.ok)
|
|
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
proxyMsg = { ok: true, text: "Saved" };
|
|
setTimeout(() => (proxyMsg = null), 3000);
|
|
} catch (e) {
|
|
proxyMsg = { ok: false, text: e.message };
|
|
} finally {
|
|
proxySaving = false;
|
|
}
|
|
}
|
|
|
|
function proxyLabel(url) {
|
|
try {
|
|
const u = new URL(url);
|
|
const proto = u.protocol.replace(":", "").toUpperCase();
|
|
const auth = u.username ? `${u.username}@` : "";
|
|
const port = u.port || (u.protocol === "http:" ? "8080" : "1080");
|
|
return { proto, host: `${auth}${u.hostname}:${port}` };
|
|
} catch { return { proto: "?", host: url }; }
|
|
}
|
|
|
|
const PROTO_COLOR = { SOCKS5: "badge-primary", SOCKS4: "badge-secondary", HTTP: "badge-neutral" };
|
|
|
|
// ── Profile helpers ───────────────────────────────────────────────────────
|
|
async function selectProfile(name) {
|
|
selectedView = 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 [tn, tc] of Object.entries(profileDetail.tools ?? {})) {
|
|
const tool = tools.find((t) => t.name === tn);
|
|
if (!tool?.config_fields?.length) continue;
|
|
nextEdits[tn] = {};
|
|
for (const f of tool.config_fields)
|
|
nextEdits[tn][f.name] = tc?.[f.name] !== undefined ? tc[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 a-z, 0-9 and hyphens";
|
|
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 (selectedView === name) { selectedView = "proxies"; 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(selectedView)}`, {
|
|
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(selectedView);
|
|
} 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(selectedView)}/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 "${selectedView}"?`)) return;
|
|
try {
|
|
await fetch(
|
|
`/api/config/profiles/${encodeURIComponent(selectedView)}/tools/${encodeURIComponent(toolName)}`,
|
|
{ method: "DELETE" }
|
|
);
|
|
await selectProfile(selectedView);
|
|
} 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-20">
|
|
<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">
|
|
|
|
<!-- ── Sidebar ──────────────────────────────────────────────────────── -->
|
|
<aside class="w-full md:w-56 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-6">
|
|
|
|
<!-- Proxies entry -->
|
|
<p class="text-xs uppercase tracking-widest text-base-content/40 px-2 mb-1">Global</p>
|
|
<button
|
|
class="flex items-center gap-2 w-full btn btn-sm
|
|
{selectedView === 'proxies' ? 'btn-primary' : 'btn-ghost'} justify-start"
|
|
onclick={() => { selectedView = 'proxies'; profileDetail = null; }}
|
|
>
|
|
{#if selectedView === 'proxies'}
|
|
<ChevronRight size={13} class="shrink-0" />
|
|
{:else}
|
|
<span class="size-[13px] shrink-0"></span>
|
|
{/if}
|
|
<Shield size={13} class="shrink-0" />
|
|
Proxies
|
|
{#if proxies.length > 0}
|
|
<span class="badge badge-xs badge-primary ml-auto">{proxies.length}</span>
|
|
{/if}
|
|
</button>
|
|
|
|
<div class="divider my-1 text-xs text-base-content/40">
|
|
<div class="flex items-center gap-2 w-full justify-between">
|
|
<span class="uppercase tracking-widest text-[10px]">Profiles</span>
|
|
{#if !configReadonly}
|
|
<button
|
|
class="btn btn-ghost btn-xs"
|
|
title="New profile"
|
|
onclick={() => { showNewProfile = !showNewProfile; newName = ""; newProfileError = ""; }}
|
|
>
|
|
<Plus size={13} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New profile inline form -->
|
|
{#if showNewProfile && !configReadonly}
|
|
<div class="flex flex-col gap-2 p-2 bg-base-300 rounded-box mb-1">
|
|
<input
|
|
type="text"
|
|
class="input input-bordered input-xs w-full font-mono
|
|
{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-[11px] 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}
|
|
|
|
<!-- Profile list -->
|
|
{#each profiles as p}
|
|
<div class="flex items-center gap-1 group">
|
|
<button
|
|
class="flex-1 btn btn-sm justify-start gap-1 truncate
|
|
{selectedView === p.name ? 'btn-primary' : 'btn-ghost'}"
|
|
onclick={() => selectProfile(p.name)}
|
|
>
|
|
{#if selectedView === p.name}
|
|
<ChevronRight size={13} class="shrink-0" />
|
|
{:else}
|
|
<span class="size-[13px] shrink-0"></span>
|
|
{/if}
|
|
{#if p.readonly}
|
|
<Lock size={10} class="shrink-0 opacity-40" />
|
|
{/if}
|
|
<span class="truncate">{p.name}</span>
|
|
</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-3">No profiles yet.</p>
|
|
{/if}
|
|
</aside>
|
|
|
|
<!-- ── Main panel ────────────────────────────────────────────────────── -->
|
|
<div class="flex-1 min-w-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}
|
|
|
|
<!-- ╌╌ Proxy panel ╌╌ -->
|
|
{#if selectedView === 'proxies'}
|
|
<div class="flex flex-col gap-5">
|
|
|
|
<div>
|
|
<h2 class="font-bold text-base flex items-center gap-2">
|
|
<Shield size={16} class="text-primary" /> Proxies
|
|
</h2>
|
|
<p class="text-sm text-base-content/50 mt-0.5">
|
|
Route all tool traffic through one or more proxies.
|
|
On network failure the next proxy is tried automatically (round-robin).
|
|
External binaries are wrapped with <code class="font-mono text-xs bg-base-300 px-1 rounded">proxychains4</code>.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- proxychains4 missing warning -->
|
|
{#if proxies.length > 0 && !proxychainsAvailable}
|
|
<div class="alert alert-warning py-2 px-3 text-sm gap-2">
|
|
<AlertTriangle size={15} class="shrink-0" />
|
|
<span>
|
|
<strong>proxychains4</strong> not found in PATH, external binary tools
|
|
(maigret, ghunt, etc.) will <strong>not</strong> be proxied.
|
|
Only HTTP-based tools are affected by the proxy config.
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Proxy list -->
|
|
<div class="flex flex-col gap-2">
|
|
{#if proxies.length === 0}
|
|
<div class="border border-dashed border-base-300 rounded-box py-8 text-center">
|
|
<Shield size={24} class="mx-auto mb-2 text-base-content/20" />
|
|
<p class="text-sm text-base-content/40">No proxies, tools connect directly.</p>
|
|
</div>
|
|
{:else}
|
|
{#each proxies as proxy, i}
|
|
{@const lbl = proxyLabel(proxy.url)}
|
|
<div class="flex items-center gap-3 bg-base-200 rounded-box px-4 py-2.5
|
|
border border-base-300 hover:border-base-content/20 transition-colors">
|
|
<span class="badge badge-sm {PROTO_COLOR[lbl.proto] ?? 'badge-ghost'} font-mono shrink-0">
|
|
{lbl.proto}
|
|
</span>
|
|
<span class="font-mono text-sm flex-1 truncate">{lbl.host}</span>
|
|
{#if !configReadonly}
|
|
<button
|
|
class="btn btn-ghost btn-xs text-error shrink-0"
|
|
onclick={() => removeProxy(i)}
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Add row -->
|
|
{#if !configReadonly}
|
|
<div class="flex flex-col gap-1.5">
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
class="input input-bordered input-sm font-mono flex-1
|
|
{newUrlError ? 'input-error' : ''}"
|
|
placeholder="socks5://user:pass@host:1080"
|
|
bind:value={newUrl}
|
|
onkeydown={(e) => e.key === 'Enter' && addProxy()}
|
|
/>
|
|
<button
|
|
class="btn btn-neutral btn-sm gap-1 shrink-0"
|
|
onclick={addProxy}
|
|
disabled={!newUrl.trim()}
|
|
>
|
|
<Plus size={14} /> Add
|
|
</button>
|
|
</div>
|
|
{#if newUrlError}
|
|
<p class="text-xs text-error flex items-center gap-1">
|
|
<AlertTriangle size={11} />{newUrlError}
|
|
</p>
|
|
{:else}
|
|
<p class="text-xs text-base-content/40">
|
|
Supported: <span class="font-mono">socks5://</span>,
|
|
<span class="font-mono">socks4://</span>,
|
|
<span class="font-mono">http://</span>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between pt-1">
|
|
<span></span>
|
|
<div class="flex items-center gap-3">
|
|
{#if proxyMsg}
|
|
<span class="text-sm {proxyMsg.ok ? 'text-success' : 'text-error'}">{proxyMsg.text}</span>
|
|
{/if}
|
|
<button
|
|
class="btn btn-primary btn-sm gap-1"
|
|
onclick={saveProxies}
|
|
disabled={proxySaving}
|
|
>
|
|
{#if proxySaving}
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
{:else}
|
|
<Save size={13} />
|
|
{/if}
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
|
|
<!-- ╌╌ Profile panel ╌╌ -->
|
|
{:else if !selectedView}
|
|
<p class="text-base-content/40 text-sm text-center py-12">Select a profile.</p>
|
|
|
|
{:else if profileLoading}
|
|
<div class="flex justify-center py-16">
|
|
<span class="loading loading-spinner loading-md"></span>
|
|
</div>
|
|
|
|
{:else if profileDetail}
|
|
<div class="flex flex-col gap-5">
|
|
|
|
<!-- Profile header -->
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<h2 class="font-bold text-base flex items-center gap-2">
|
|
<SlidersHorizontal size={16} class="text-primary" />
|
|
{#if isReadonly}<Lock size={13} class="text-base-content/40" />{/if}
|
|
{selectedView}
|
|
</h2>
|
|
{#if isReadonly}<Badge text="read-only" size="sm" />{/if}
|
|
{#if profileDetail.active_tools?.length > 0}
|
|
<span class="text-xs text-base-content/50 ml-auto">
|
|
{profileDetail.active_tools.length} active tool{profileDetail.active_tools.length !== 1 ? "s" : ""}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
{#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/40">Notes</span>
|
|
<textarea
|
|
class="textarea textarea-bordered text-sm resize-none"
|
|
placeholder="Describe this profile..."
|
|
rows="2"
|
|
bind:value={notesEdit}
|
|
></textarea>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Rules card -->
|
|
<div class="card bg-base-200 border border-base-300">
|
|
<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/40">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="If non-empty, only these tools will run for this profile." />
|
|
</span>
|
|
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
|
{#if isReadonly}
|
|
{#each (profileDetail.enabled ?? []) as tn}
|
|
<span class="badge badge-outline gap-1">{tn}</span>
|
|
{/each}
|
|
{#if (profileDetail.enabled ?? []).length === 0}
|
|
<span class="text-xs text-base-content/40">All tools</span>
|
|
{/if}
|
|
{:else}
|
|
{#each enabledEdit as tn}
|
|
<span class="badge badge-outline gap-1">
|
|
{tn}
|
|
<button onclick={() => (enabledEdit = enabledEdit.filter((x) => x !== tn))}>
|
|
<X size={10} />
|
|
</button>
|
|
</span>
|
|
{/each}
|
|
<Select
|
|
options={allToolNames.filter((n) => !enabledEdit.includes(n))}
|
|
placeholder="add tool" size="xs"
|
|
onselect={(v) => (enabledEdit = [...enabledEdit, v])}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<span class="text-sm font-semibold">
|
|
Disabled
|
|
<InfoTip tooltip="These tools are always skipped, even if they appear in the enabled list." />
|
|
</span>
|
|
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
|
{#if isReadonly}
|
|
{#each (profileDetail.disabled ?? []) as tn}
|
|
<span class="badge badge-error gap-1">{tn}</span>
|
|
{/each}
|
|
{#if (profileDetail.disabled ?? []).length === 0}
|
|
<span class="text-xs text-base-content/40">None</span>
|
|
{/if}
|
|
{:else}
|
|
{#each disabledEdit as tn}
|
|
<span class="badge badge-error gap-1">
|
|
{tn}
|
|
<button onclick={() => (disabledEdit = disabledEdit.filter((x) => x !== tn))}>
|
|
<X size={10} />
|
|
</button>
|
|
</span>
|
|
{/each}
|
|
<Select
|
|
options={allToolNames.filter((n) => !disabledEdit.includes(n))}
|
|
placeholder="add tool" size="xs"
|
|
onselect={(v) => (disabledEdit = [...disabledEdit, v])}
|
|
/>
|
|
{/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={13} />
|
|
{/if}
|
|
Save
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Overrides card -->
|
|
{#if !isReadonly}
|
|
<div class="card bg-base-200 border border-base-300">
|
|
<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/40">Tool overrides</h3>
|
|
{#if availableForOverride.length > 0}
|
|
<Select
|
|
options={availableForOverride.map((t) => t.name)}
|
|
placeholder="add override" size="xs"
|
|
onselect={(v) => addOverrideFor(v)}
|
|
/>
|
|
{/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={13} />
|
|
{/if}
|
|
Save
|
|
</button>
|
|
{:else}
|
|
<p class="text-xs text-base-content/40">No configurable fields.</p>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
</div>
|
|
{/if}
|