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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Browser → RelayKit App → Dokploy API
| Strfry | [hoytech/strfry](https://github.com/hoytech/strfry) | |
| Blossom | [hzrd149/blossom](https://github.com/hzrd149/blossom) | |
| nPanel | [hzrd149/nsite-gateway](https://github.com/hzrd149/nsite-gateway) | Static sites on Nostr (NIP-5A) plus same-domain NIP-05 responses. Deploys nsite-gateway, a NIP-05 JSON sidecar, and a Caddy sidecar that serves `/.well-known/nostr.json` while rewriting `Host` for gateway traffic. |
| Compute DVM | [`app/presets/dvm-compute/worker/`](app/presets/dvm-compute/worker/) | Programmable, caching DVM (NIP-90): runs whitelisted Nostr code snippets (NIP-C0) in a quickjs sandbox and publishes cached results (`kind:31338`) clients read instead of crawling raw events. |

## Project Structure

Expand Down Expand Up @@ -184,6 +185,12 @@ issues:
- [ ] dns check in prod not working right

next:
- [ ] data functions
- [ ] code ui rendering
- [ ] info to help query the new data
- [ ] human readable ttls
- [ ] runtime logs per run?
- [ ] fork a 1337/31337
- [ ] convey real deployment status in UI (not assumed success) - especially as it starts up, to know when it's "ready"
- [ ] inc adding a new service, doesn't appear immediately in the ui
- [ ] restart ui option.
Expand Down
121 changes: 121 additions & 0 deletions app/frontend/src/components/RelayInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ActionIcon, Group, TagsInput, Text } from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { trpc } from '../trpc';
import { isRelayType } from '../../../shared/serviceType';

// normalize a host/url to a ws(s) url
export const toWs = (url: string): string => {
const u = url.trim();
if (!u) return '';
if (u.startsWith('ws://') || u.startsWith('wss://')) return u;
if (u.startsWith('http://')) return 'ws://' + u.slice('http://'.length);
if (u.startsWith('https://')) return 'wss://' + u.slice('https://'.length);
return 'wss://' + u;
};

// relays the user has typed before, remembered across sessions
const RELAY_HISTORY_KEY = 'relaykit:relay-history';
const loadRelayHistory = (): string[] => {
try {
const r = JSON.parse(localStorage.getItem(RELAY_HISTORY_KEY) || '[]');
return Array.isArray(r) ? r.filter((x) => typeof x === 'string') : [];
} catch {
return [];
}
};
const rememberRelays = (relays: string[]): string[] => {
const merged = [...new Set([...relays, ...loadRelayHistory()].map((r) => r.trim()).filter(Boolean))].slice(0, 30);
localStorage.setItem(RELAY_HISTORY_KEY, JSON.stringify(merged));
return merged;
};

type RelayInputProps = {
label?: ReactNode;
description?: string;
placeholder?: string;
value: string[];
onChange: (relays: string[]) => void;
// single relay (renders as one chip) vs many
multiple?: boolean;
required?: boolean;
};

// Shared relay picker: chips + free entry, a dropdown seeded with this instance's relays plus the
// user's remembered relays, and an × to forget a remembered one.
export const RelayInput = ({
label,
description,
placeholder,
value,
onChange,
multiple = true,
required,
}: RelayInputProps) => {
const [relayHistory, setRelayHistory] = useState<string[]>(loadRelayHistory);
const [appRelays, setAppRelays] = useState<string[]>([]);

useEffect(() => {
let cancelled = false;
trpc.listServices
.query()
.then((services: any[]) => {
if (cancelled) return;
const urls = services
.filter((s) => isRelayType(s.type))
.map((s) => s.domains?.[0]?.host ?? s.hostname)
.filter((h: string) => h && h !== 'No hostname configured')
.map((h: string) => toWs(h));
setAppRelays([...new Set(urls)]);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

const removeFromHistory = (relay: string) => {
const next = loadRelayHistory().filter((r) => r !== relay);
localStorage.setItem(RELAY_HISTORY_KEY, JSON.stringify(next));
setRelayHistory(next);
};

return (
<TagsInput
label={label}
description={description}
placeholder={value.length ? '' : placeholder}
required={required}
value={value}
data={[...new Set([...appRelays, ...relayHistory])]}
maxTags={multiple ? undefined : 1}
clearable
splitChars={[',', ' ']}
onChange={(vals) => {
const cleaned = vals.map(toWs).filter(Boolean);
onChange(cleaned);
setRelayHistory(rememberRelays(cleaned));
}}
renderOption={({ option }) => (
<Group justify="space-between" wrap="nowrap" w="100%" gap="xs">
<Text size="sm" truncate>{option.value}</Text>
{!appRelays.includes(option.value) && (
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label="remove from history"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
removeFromHistory(option.value);
}}
>
<IconX size={13} />
</ActionIcon>
)}
</Group>
)}
/>
);
};
129 changes: 129 additions & 0 deletions app/frontend/src/components/ServiceConfigPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Group, Loader, ScrollArea, Stack, Switch, Text, TextInput } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { toast } from 'sonner';
import { trpc } from '../trpc';
import { SERVICE_TYPE, isNpanelType } from '../../../shared/serviceType';
import { NsiteDeployFields, prepareNsiteConfigForSave } from './NsiteDeployFields';

type Field = { id: string; name: string; description?: string; type?: string; required?: boolean; default?: string };

// The service's deploy-time config (env vars from the preset's requiredConfig), rendered inline
// as a tab. Saving redeploys the service — same backend path the old modal used.
export const ServiceConfigPanel = ({ service, onSaved }: { service: any; onSaved?: () => void }) => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fields, setFields] = useState<Field[]>([]);
const [values, setValues] = useState<Record<string, string>>({});

const isNsite = isNpanelType(service?.type);

useEffect(() => {
let cancelled = false;
setLoading(true);
trpc.getServiceConfig
.query({ composeId: service.composeId })
.then((res: any) => {
if (cancelled) return;
setFields(res.fields || []);
setValues(res.config || {});
})
.catch((e: any) => !cancelled && setError(e?.message ?? 'failed to load config'))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [service.composeId]);

const setField = (id: string, value: string) => setValues((v) => ({ ...v, [id]: value }));

const missingRequired = useMemo(
() => !isNsite && fields.some((f) => f.required && f.type !== 'boolean' && !(values[f.id] || '').trim()),
[fields, values, isNsite],
);

const save = async () => {
setSaving(true);
try {
const config = isNsite ? prepareNsiteConfigForSave(values) : values;
await trpc.updateServiceConfig.mutate({ composeId: service.composeId, config });
toast.success('config updated, redeploy started');
onSaved?.();
} catch (e: any) {
toast.error(`failed to update config: ${e?.message ?? e}`);
} finally {
setSaving(false);
}
};

if (loading) {
return (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">loading config…</Text>
</Group>
);
}

if (error) {
return (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="error">
{error}
</Alert>
);
}

const renderField = (field: Field) => {
if (field.type === 'boolean') {
const checked = (values[field.id] || String(field.default || 'false')).toLowerCase() === 'true';
return (
<Switch
key={field.id}
label={field.name}
description={field.description}
checked={checked}
onChange={(e) => setField(field.id, e.currentTarget.checked ? 'true' : 'false')}
/>
);
}
return (
<TextInput
key={field.id}
label={field.name}
description={field.description}
required={field.required}
value={values[field.id] ?? String(field.default ?? '')}
onChange={(e) => setField(field.id, e.currentTarget.value)}
/>
);
};

return (
<ScrollArea.Autosize mah="68vh" type="auto" offsetScrollbars>
<Stack gap="md" pr="sm">
{fields.length === 0 ? (
<Text size="sm" c="dimmed">no editable config for this service.</Text>
) : (
<>
{isNsite ? (
<NsiteDeployFields
preset={{ id: SERVICE_TYPE.NPANEL, requiredConfig: fields }}
config={values}
setConfig={(next: any) => setValues((prev) => (typeof next === 'function' ? next(prev) : next))}
ownerPubkeyHex={null}
/>
) : (
fields.map(renderField)
)}
<Group justify="flex-end">
<Button color="green" loading={saving} disabled={saving || missingRequired} onClick={save}>
{saving ? 'saving…' : 'save + redeploy'}
</Button>
</Group>
</>
)}
</Stack>
</ScrollArea.Autosize>
);
};
30 changes: 28 additions & 2 deletions app/frontend/src/components/ServiceDetailsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Text, Group, Anchor, Tooltip, ActionIcon, Button, Stack, Badge, Tabs, B
import { IconExternalLink, IconCheck, IconX, IconAlertOctagon, IconAlertTriangle, IconCircleCheck, IconRefresh, IconPencil } from '@tabler/icons-react';
import { InlineTextEditRow } from './InlineTextEditRow';
import { CopyControl } from './CopyControl';
import { ServiceDetailsScripts } from './ServiceDetailsScripts';
import { ServiceConfigPanel } from './ServiceConfigPanel';
import { trpc } from '../trpc';
import { serviceTypeToRubixLoaderColor } from '../lib/serviceTypeColor';
import { formatBytes, formatBytesPerSecond, formatPercent, formatWindow, getInsightSeverity, getOverallSeverity, getSeverityColor } from '../../../shared/insights';
Expand Down Expand Up @@ -921,6 +923,8 @@ export const ServiceDetailsContent = (props: ServiceDetailsContentProps) => {
const hasDNS = domain && serverIp;
const hasInsights = !!service.composeId;
const hasLogs = !!service.composeId;
const hasScripts = !!service.composeId && service.presetId === 'dvm-compute';
const hasConfig = !!service.composeId;
const activePanelBg = colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0];
const panelShadow = colorScheme === 'dark'
? '0 4px 12px rgba(0,0,0,0.18)'
Expand Down Expand Up @@ -1017,9 +1021,13 @@ export const ServiceDetailsContent = (props: ServiceDetailsContentProps) => {
>
<Tabs.List aria-label="Details sections" miw={rem(132)} style={{ flexShrink: 0 }}>
<Tabs.Tab value="info" style={getTabStyle(section === 'info')}>info</Tabs.Tab>
<Tabs.Tab value="dns" style={getTabStyle(section === 'dns')}>dns</Tabs.Tab>
<Tabs.Tab value="insights" style={getTabStyle(section === 'insights')}>insights</Tabs.Tab>
{!hasScripts && <Tabs.Tab value="dns" style={getTabStyle(section === 'dns')}>dns</Tabs.Tab>}
{!hasScripts && <Tabs.Tab value="insights" style={getTabStyle(section === 'insights')}>insights</Tabs.Tab>}
<Tabs.Tab value="logs" style={getTabStyle(section === 'logs')}>logs</Tabs.Tab>
{hasScripts && (
<Tabs.Tab value="scripts" style={getTabStyle(section === 'scripts')}>data functions</Tabs.Tab>
)}
{hasConfig && <Tabs.Tab value="config" style={getTabStyle(section === 'config')}>config</Tabs.Tab>}
</Tabs.List>
<Box
style={{
Expand Down Expand Up @@ -1077,6 +1085,24 @@ export const ServiceDetailsContent = (props: ServiceDetailsContentProps) => {
</Box>
)}
</Transition>
{hasScripts && (
<Transition transition="fade" duration={FADE_MS} exitDuration={0} mounted={section === 'scripts'}>
{(tStyle) => (
<Box style={{ ...panelContentStyle, ...tStyle }}>
<ServiceDetailsScripts composeId={service.composeId} />
</Box>
)}
</Transition>
)}
{hasConfig && (
<Transition transition="fade" duration={FADE_MS} exitDuration={0} mounted={section === 'config'}>
{(tStyle) => (
<Box style={{ ...panelContentStyle, ...tStyle }}>
<ServiceConfigPanel service={service} />
</Box>
)}
</Transition>
)}
</Box>
</Group>
</Tabs>
Expand Down
Loading