updated update
This commit is contained in:
@@ -3,6 +3,19 @@
|
|||||||
"account": "Account",
|
"account": "Account",
|
||||||
"already_have_account": "Already have an account?",
|
"already_have_account": "Already have an account?",
|
||||||
"appname": "NadiЯ",
|
"appname": "NadiЯ",
|
||||||
|
"agent_updates":"Agent outdated",
|
||||||
|
"agent_update_started":"Updating agent... ({from} → {to})",
|
||||||
|
"agent_update_success":"Agent updated to {version}",
|
||||||
|
"agent_update_failed":"Agent update did not complete - check `nadir logs` on the host",
|
||||||
|
"machine_offline_title":"{name} is offline",
|
||||||
|
"machine_offline_description":"Could not reach the nadir-agent at {address}. The host may be down, the agent stopped, or the address is wrong.",
|
||||||
|
"machine_offline_code":"Error 502",
|
||||||
|
"machine_offline_node_you":"You",
|
||||||
|
"machine_offline_node_proxy":"Web UI",
|
||||||
|
"machine_offline_node_dest":"Agent",
|
||||||
|
"machine_offline_status_connected":"CONNECTED",
|
||||||
|
"machine_offline_status_unreachable":"UNREACHABLE",
|
||||||
|
"machine_offline_details":"Show error details",
|
||||||
"back_to_login": "Back to login",
|
"back_to_login": "Back to login",
|
||||||
"backup_code": "Backup code",
|
"backup_code": "Backup code",
|
||||||
"backup_codes_notice": "Store these somewhere safe. Each code works once if you lose your device.",
|
"backup_codes_notice": "Store these somewhere safe. Each code works once if you lose your device.",
|
||||||
|
|||||||
@@ -190,6 +190,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display:grid;
|
||||||
|
place-content:center
|
||||||
}
|
}
|
||||||
.trunk :global(canvas) {
|
.trunk :global(canvas) {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { SystemDetails } from './types';
|
import type { SystemDetails } from './types';
|
||||||
|
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
import { ghz } from './format';
|
import { ghz } from './format';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -36,8 +37,10 @@
|
|||||||
|
|
||||||
{#snippet section(heading: string, body: Snippet)}
|
{#snippet section(heading: string, body: Snippet)}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<span class="text-foreground text-[0.7rem] font-medium tracking-wide uppercase">{heading}</span>
|
<Badge variant="outline" class="rounded-sm">{heading}</Badge>
|
||||||
{@render body()}
|
<div class="px-1">
|
||||||
|
{@render body()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||||
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
outline: 'border-foreground /50 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80'
|
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { m } from '$lib/paraglide/messages';
|
|||||||
const nonEmpty = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()));
|
const nonEmpty = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()));
|
||||||
|
|
||||||
const address = v.pipe(
|
const address = v.pipe(
|
||||||
v.optional(v.string(), 'http://127.0.0.1:9999'),
|
v.optional(v.string(), 'https://127.0.0.1:9999'),
|
||||||
v.transform((s) => s.trim() || 'http://127.0.0.1:9999'),
|
v.transform((s) => {
|
||||||
|
const t = s.trim() || 'https://127.0.0.1:9999';
|
||||||
|
return /^https?:\/\//i.test(t) ? t : `https://${t}`;
|
||||||
|
}),
|
||||||
v.url(m.errors_address_invalid())
|
v.url(m.errors_address_invalid())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -17,7 +20,11 @@ export const machineSchema = v.object({ address, name: nonEmpty, token: nonEmpty
|
|||||||
export const machineEditSchema = v.object({
|
export const machineEditSchema = v.object({
|
||||||
address: v.pipe(
|
address: v.pipe(
|
||||||
v.optional(v.string(), ''),
|
v.optional(v.string(), ''),
|
||||||
v.transform((s) => s.trim())
|
v.transform((s) => {
|
||||||
|
const t = s.trim();
|
||||||
|
if (!t) return t;
|
||||||
|
return /^https?:\/\//i.test(t) ? t : `https://${t}`;
|
||||||
|
})
|
||||||
),
|
),
|
||||||
id: nonEmpty,
|
id: nonEmpty,
|
||||||
name: v.pipe(
|
name: v.pipe(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getRequestEvent, query } from '$app/server';
|
import { command, getRequestEvent, query } from '$app/server';
|
||||||
import { v } from '$lib';
|
import { v } from '$lib';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
@@ -18,12 +18,25 @@ export const serverInfo = query(async () => {
|
|||||||
if (!token) error(500, { message: m.errors_generic() });
|
if (!token) error(500, { message: m.errors_generic() });
|
||||||
const nadir = getClient(machine.address, token);
|
const nadir = getClient(machine.address, token);
|
||||||
|
|
||||||
const { data, error: err } = await nadir.GET('/api/system/info');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { token: _, ...safe } = machine;
|
const { token: _, ...safe } = machine;
|
||||||
if (data) return { ...data, $db: safe };
|
try {
|
||||||
|
const [info, health] = await Promise.all([
|
||||||
throw error(err.status || 500, { message: err.detail || m.errors_generic() });
|
nadir.GET('/api/system/info'),
|
||||||
|
nadir.GET('/api/health')
|
||||||
|
]);
|
||||||
|
const { data, error: err } = info;
|
||||||
|
if (data)
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
$agent_version: (health.data as { version?: string } | undefined)?.version ?? null,
|
||||||
|
$db: safe,
|
||||||
|
$offline: null
|
||||||
|
};
|
||||||
|
return { $db: safe, $offline: err.detail || m.errors_generic() };
|
||||||
|
} catch (e) {
|
||||||
|
return { $db: safe, $offline: (e as Error)?.message || m.errors_generic() };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const auditLog = query(v.optional(v.number(), 20), async (limit) => {
|
export const auditLog = query(v.optional(v.number(), 20), async (limit) => {
|
||||||
@@ -37,10 +50,12 @@ export const auditLog = query(v.optional(v.number(), 20), async (limit) => {
|
|||||||
const token = decryptValue(machine.token);
|
const token = decryptValue(machine.token);
|
||||||
if (!token) error(500, { message: m.errors_generic() });
|
if (!token) error(500, { message: m.errors_generic() });
|
||||||
const nadir = getClient(machine.address, token);
|
const nadir = getClient(machine.address, token);
|
||||||
const { data, error: err } = await nadir.GET('/api/audit', { params: { query: { limit } } });
|
try {
|
||||||
if (data) return data.entries ?? [];
|
const { data } = await nadir.GET('/api/audit', { params: { query: { limit } } });
|
||||||
console.error(err);
|
return data?.entries ?? [];
|
||||||
throw error(err.status || 500, { message: err.detail || m.errors_generic() });
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ponytail: in-memory cache, 10 min; switch to Redis/KV if we ever run >1 node
|
// ponytail: in-memory cache, 10 min; switch to Redis/KV if we ever run >1 node
|
||||||
@@ -59,6 +74,21 @@ export const latestAgentRelease = query(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateAgent = command(async () => {
|
||||||
|
const {
|
||||||
|
locals: { user },
|
||||||
|
params: { machineId }
|
||||||
|
} = getRequestEvent();
|
||||||
|
if (!user) error(401, { message: m.errors_unauthenticated() });
|
||||||
|
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
|
||||||
|
if (!machine) error(404, { message: m.errors_not_found() });
|
||||||
|
const token = decryptValue(machine.token);
|
||||||
|
if (!token) error(500, { message: m.errors_generic() });
|
||||||
|
const nadir = getClient(machine.address, token);
|
||||||
|
const { error: err } = await nadir.POST('/api/update');
|
||||||
|
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
|
||||||
|
});
|
||||||
|
|
||||||
export const systemDetails = query(async () => {
|
export const systemDetails = query(async () => {
|
||||||
const {
|
const {
|
||||||
locals: { user },
|
locals: { user },
|
||||||
@@ -70,14 +100,14 @@ export const systemDetails = query(async () => {
|
|||||||
const token = decryptValue(machine.token);
|
const token = decryptValue(machine.token);
|
||||||
if (!token) error(500, { message: m.errors_generic() });
|
if (!token) error(500, { message: m.errors_generic() });
|
||||||
const nadir = getClient(machine.address, token);
|
const nadir = getClient(machine.address, token);
|
||||||
const [time, locale, dns, updates] = await Promise.all([
|
|
||||||
nadir.GET('/api/system/time'),
|
|
||||||
nadir.GET('/api/system/locale'),
|
|
||||||
nadir.GET('/api/networking/dns'),
|
|
||||||
nadir.GET('/api/packages/updates')
|
|
||||||
]);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { token: _, ...safe } = machine;
|
const { token: _, ...safe } = machine;
|
||||||
|
const [time, locale, dns, updates] = await Promise.all([
|
||||||
|
nadir.GET('/api/system/time').catch(() => ({ data: undefined })),
|
||||||
|
nadir.GET('/api/system/locale').catch(() => ({ data: undefined })),
|
||||||
|
nadir.GET('/api/networking/dns').catch(() => ({ data: undefined })),
|
||||||
|
nadir.GET('/api/packages/updates').catch(() => ({ data: undefined }))
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
$db: safe,
|
$db: safe,
|
||||||
dns: dns.data?.servers ?? null,
|
dns: dns.data?.servers ?? null,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { getRequestEvent } from '$app/server';
|
|
||||||
import createClient from 'openapi-fetch';
|
import createClient from 'openapi-fetch';
|
||||||
|
|
||||||
import type { paths } from './schema';
|
import type { paths } from './schema';
|
||||||
|
|
||||||
|
// Use the global fetch (NOT event.fetch): SvelteKit's event.fetch forwards the
|
||||||
|
// browser request's Origin header to outbound calls, which trips the agent's
|
||||||
|
// CSRF same-origin check on POST/PUT/DELETE. Server-to-server fetch has no
|
||||||
|
// Origin, so the agent treats it as a non-browser caller and lets writes through.
|
||||||
const getClient = (baseUrl: string, token: string) => {
|
const getClient = (baseUrl: string, token: string) => {
|
||||||
const { fetch } = getRequestEvent();
|
|
||||||
return createClient<paths>({
|
return createClient<paths>({
|
||||||
baseUrl: baseUrl,
|
baseUrl,
|
||||||
fetch,
|
fetch: globalThis.fetch,
|
||||||
headers: {
|
headers: { authorization: `Bearer ${token}` }
|
||||||
authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+72
@@ -948,6 +948,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/update": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Update nadir to the latest release
|
||||||
|
* @description Equivalent to running `sudo nadir update` on the host: queries server.release_repo for the latest release, downloads the binary matching the host's architecture, atomically replaces the running binary, and restarts the systemd unit. Returns 202 immediately; the service restart drops in-flight connections, so poll /api/health to confirm the new version is up. Requires the wildcard admin role.
|
||||||
|
*/
|
||||||
|
post: operations["meta-update"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users": {
|
"/api/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1380,6 +1400,11 @@ export interface components {
|
|||||||
* @example ok
|
* @example ok
|
||||||
*/
|
*/
|
||||||
status: string;
|
status: string;
|
||||||
|
/**
|
||||||
|
* @description Application version
|
||||||
|
* @example 1.0.0
|
||||||
|
*/
|
||||||
|
version: string;
|
||||||
};
|
};
|
||||||
HostEntry: {
|
HostEntry: {
|
||||||
/**
|
/**
|
||||||
@@ -5792,6 +5817,53 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"meta-update": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Accepted */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["StatusOutputBody"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ErrorModel"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ErrorModel"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal Server Error */
|
||||||
|
500: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ErrorModel"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
"users-list": {
|
"users-list": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
|
ClockAlert,
|
||||||
Cpu,
|
Cpu,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
|
Globe,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
Pause,
|
Pause,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2
|
Server,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
X
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import Trunk from '$lib/components/blocks/trunk/trunk.svelte';
|
||||||
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
|
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
|
||||||
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
|
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -30,8 +36,11 @@
|
|||||||
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.svelte';
|
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.svelte';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ButtonGroup } from '$lib/components/ui/button-group';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import * as Empty from '$lib/components/ui/empty';
|
||||||
import * as Field from '$lib/components/ui/field';
|
import * as Field from '$lib/components/ui/field';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Progress } from '$lib/components/ui/progress';
|
import { Progress } from '$lib/components/ui/progress';
|
||||||
@@ -43,7 +52,8 @@
|
|||||||
auditLog,
|
auditLog,
|
||||||
latestAgentRelease,
|
latestAgentRelease,
|
||||||
serverInfo,
|
serverInfo,
|
||||||
systemDetails
|
systemDetails,
|
||||||
|
updateAgent
|
||||||
} from '$lib/remotes/server.remote';
|
} from '$lib/remotes/server.remote';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
@@ -87,6 +97,7 @@
|
|||||||
|
|
||||||
let editOpen = $state(false);
|
let editOpen = $state(false);
|
||||||
let deleteOpen = $state(false);
|
let deleteOpen = $state(false);
|
||||||
|
let updating = $state(false);
|
||||||
const formId = $props.id();
|
const formId = $props.id();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,180 +107,311 @@
|
|||||||
{console.log('errore', err)}
|
{console.log('errore', err)}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{@const sys = await info}
|
{@const raw = await info}
|
||||||
{@const log = await audit}
|
{#if raw.$offline !== null}
|
||||||
{@const d = await details}
|
<div class="flex grow items-center justify-center p-8 relative">
|
||||||
{@const latestTag = await latest}
|
<Trunk>
|
||||||
{@const agentVersion = (sys as unknown as { agent_version?: string }).agent_version}
|
<Card.Root class="relative m-auto z-20">
|
||||||
{@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)}
|
<Card.Content>
|
||||||
{@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)}
|
<Empty.Root class="max-w-2xl">
|
||||||
{@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)}
|
<Empty.Header>
|
||||||
{@const swapPct = pct(
|
<p class="text-destructive font-mono text-sm">{m.machine_offline_code()}</p>
|
||||||
sys.memory.swap_total_bytes - sys.memory.swap_free_bytes,
|
<Empty.Title>{m.machine_offline_title({ name: raw.$db.name ?? '' })}</Empty.Title>
|
||||||
sys.memory.swap_total_bytes
|
<Empty.Description>
|
||||||
)}
|
{m.machine_offline_description({ address: raw.$db.address })}
|
||||||
|
</Empty.Description>
|
||||||
<div
|
</Empty.Header>
|
||||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
|
<Empty.Content>
|
||||||
>
|
<div class="flex w-full items-start justify-center gap-2 py-4 sm:gap-4">
|
||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{sys.$db.name}</h1>
|
<div
|
||||||
<p class="text-muted-foreground text-sm truncate">
|
class="bg-muted text-foreground flex size-14 items-center justify-center rounded-lg"
|
||||||
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
|
>
|
||||||
</p>
|
<User class="size-6" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-medium">{m.machine_offline_node_you()}</div>
|
||||||
|
<div class="text-[10px] font-mono tracking-wider text-emerald-500">
|
||||||
|
{m.machine_offline_status_connected()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative mt-6 h-1 flex-1 self-start">
|
||||||
|
<div class="custom-dash custom-dash-ok h-1 w-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="bg-muted text-foreground flex size-14 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<Server class="size-6" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-medium">{m.machine_offline_node_proxy()}</div>
|
||||||
|
<div class="text-[10px] font-mono tracking-wider text-emerald-500">
|
||||||
|
{m.machine_offline_status_connected()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative mt-6 h-1 flex-1 self-start">
|
||||||
|
<div class="custom-dash custom-dash-bad h-1 w-full"></div>
|
||||||
|
<div
|
||||||
|
class="bg-card text-destructive absolute top-1/2 left-1/2 flex size-5.5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="bg-muted text-foreground flex size-14 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<Globe class="size-6" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-medium">{m.machine_offline_node_dest()}</div>
|
||||||
|
<div class="text-destructive text-[10px] font-mono tracking-wider">
|
||||||
|
{m.machine_offline_status_unreachable()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-muted-foreground w-full max-w-full text-xs">
|
||||||
|
<summary class="cursor-pointer select-none"
|
||||||
|
>{m.machine_offline_details()}</summary
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
class="bg-muted mt-2 max-w-full overflow-auto rounded-md p-3 whitespace-pre-wrap text-center">{raw.$offline}</pre>
|
||||||
|
</details>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<Button onclick={() => info.refresh()}>
|
||||||
|
<RefreshCw class="size-4" />
|
||||||
|
{m.dashboard_refresh()}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onclick={() => (editOpen = true)}>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{m.edit()}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onclick={() => (deleteOpen = true)}>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
{m.delete()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Empty.Content>
|
||||||
|
</Empty.Root>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</Trunk>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
|
{:else}
|
||||||
<Select.Root type="single" bind:value={syncInterval}>
|
{@const sys = raw}
|
||||||
<Select.Trigger class="h-8 w-auto grow lg:w-35">{intervalLabel}</Select.Trigger>
|
{@const log = await audit}
|
||||||
<Select.Content>
|
{@const d = await details}
|
||||||
{#each intervals as opt (opt.value)}
|
{@const latestTag = await latest}
|
||||||
<Select.Item value={opt.value}>{opt.label}</Select.Item>
|
{@const agentVersion = sys.$agent_version}
|
||||||
{/each}
|
{@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)}
|
||||||
</Select.Content>
|
{@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)}
|
||||||
</Select.Root>
|
{@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)}
|
||||||
{#if syncInterval === 'custom'}
|
{@const swapPct = pct(
|
||||||
<div class="flex items-center gap-1.5">
|
sys.memory.swap_total_bytes - sys.memory.swap_free_bytes,
|
||||||
<Input type="number" min="1" bind:value={customSec} class="h-8 w-16" />
|
sys.memory.swap_total_bytes
|
||||||
</div>
|
)}
|
||||||
{/if}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
onclick={() => (polling = !polling)}
|
|
||||||
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
|
|
||||||
>
|
|
||||||
{#if polling}
|
|
||||||
<Pause class="size-4" />
|
|
||||||
{:else}
|
|
||||||
<Play class="size-4" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
class="h-8 px-2 sm:px-3"
|
|
||||||
onclick={refreshAll}
|
|
||||||
aria-label={m.dashboard_refresh()}
|
|
||||||
>
|
|
||||||
<RefreshCw class="size-4" />
|
|
||||||
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 relative"
|
|
||||||
aria-label={m.machine_actions()}
|
|
||||||
>
|
|
||||||
<Ellipsis class="size-4" />
|
|
||||||
{#if outdated}
|
|
||||||
<span
|
|
||||||
class="absolute top-1 right-1 size-2 rounded-full bg-red-500 ring-2 ring-background"
|
|
||||||
title={`nadir-agent ${agentVersion} → ${latestTag}`}
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content align="end">
|
|
||||||
<DropdownMenu.Item onSelect={() => (editOpen = true)}>
|
|
||||||
<Pencil class="size-4" />
|
|
||||||
{m.edit()}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item variant="destructive" onSelect={() => (deleteOpen = true)}>
|
|
||||||
<Trash2 class="size-4" />
|
|
||||||
{m.delete()}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-2">
|
<div
|
||||||
<!-- CPU Heatmap (replaces the old CPU KpiCard) -->
|
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
|
||||||
<KpiCard
|
|
||||||
label={m.dashboard_cpu()}
|
|
||||||
icon={Cpu}
|
|
||||||
value={sys.cpu.model}
|
|
||||||
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KpiCard
|
|
||||||
label={m.dashboard_memory()}
|
|
||||||
icon={MemoryStick}
|
|
||||||
value="{memPct}%"
|
|
||||||
valueClass="tabular-nums {usageText(memPct)}"
|
|
||||||
>
|
>
|
||||||
{#snippet extra()}
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div
|
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
|
||||||
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
|
{sys.$db.name}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground text-sm truncate">
|
||||||
|
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
|
||||||
|
<ButtonGroup>
|
||||||
|
<Select.Root type="single" bind:value={syncInterval}>
|
||||||
|
<Select.Trigger class="h-8 w-auto grow lg:w-35">{intervalLabel}</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each intervals as opt (opt.value)}
|
||||||
|
<Select.Item value={opt.value}>{opt.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if syncInterval === 'custom'}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Input type="number" min="1" bind:value={customSec} class="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => (polling = !polling)}
|
||||||
|
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
|
||||||
|
>
|
||||||
|
{#if polling}
|
||||||
|
<Pause class="size-4!" />
|
||||||
|
{:else}
|
||||||
|
<Play class="size-4!" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 px-2 sm:px-3"
|
||||||
|
onclick={refreshAll}
|
||||||
|
aria-label={m.dashboard_refresh()}
|
||||||
>
|
>
|
||||||
<span>{gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
|
<RefreshCw class="size-4!" />
|
||||||
</div>
|
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
|
||||||
<Progress value={memPct} class={usageBar(memPct)} />
|
</Button>
|
||||||
{#if sys.memory.swap_total_bytes > 0}
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 relative"
|
||||||
|
aria-label={m.machine_actions()}
|
||||||
|
>
|
||||||
|
<Ellipsis class="size-4!" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onSelect={() => (editOpen = true)}>
|
||||||
|
<Pencil class="size-4!" />
|
||||||
|
{m.edit()}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item variant="destructive" onSelect={() => (deleteOpen = true)}>
|
||||||
|
<Trash2 class="size-4!" />
|
||||||
|
{m.delete()}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
{#if outdated}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
title={`${m.agent_updates()}: ${agentVersion} → ${latestTag}`}
|
||||||
|
disabled={updating}
|
||||||
|
onclick={async () => {
|
||||||
|
updating = true;
|
||||||
|
const from = agentVersion ?? '?';
|
||||||
|
const to = latestTag ?? '?';
|
||||||
|
try {
|
||||||
|
await updateAgent();
|
||||||
|
toast.info(m.agent_update_started({ from, to }));
|
||||||
|
// Agent returns 202 and runs update in background; poll until the
|
||||||
|
// reported version flips or we give up.
|
||||||
|
const deadline = Date.now() + 60_000;
|
||||||
|
let done = false;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
await info.refresh().catch(() => {});
|
||||||
|
const next = await info;
|
||||||
|
const v = next.$offline === null ? next.$agent_version : null;
|
||||||
|
if (v && v !== from) {
|
||||||
|
toast.success(m.agent_update_success({ version: v }));
|
||||||
|
await refreshAll();
|
||||||
|
done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!done) toast.error(m.agent_update_failed());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
(e as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
updating = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClockAlert class="size-4!" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-2">
|
||||||
|
<!-- CPU Heatmap (replaces the old CPU KpiCard) -->
|
||||||
|
<KpiCard
|
||||||
|
label={m.dashboard_cpu()}
|
||||||
|
icon={Cpu}
|
||||||
|
value={sys.cpu.model}
|
||||||
|
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiCard
|
||||||
|
label={m.dashboard_memory()}
|
||||||
|
icon={MemoryStick}
|
||||||
|
value="{memPct}%"
|
||||||
|
valueClass="tabular-nums {usageText(memPct)}"
|
||||||
|
>
|
||||||
|
{#snippet extra()}
|
||||||
<div
|
<div
|
||||||
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
|
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
|
||||||
>
|
>
|
||||||
<span
|
<span>{gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
|
||||||
>{gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb(
|
|
||||||
sys.memory.swap_total_bytes
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<span>{m.dashboard_swap({ swapPct })}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={swapPct} class={usageBar(swapPct)} />
|
<Progress value={memPct} class={usageBar(memPct)} />
|
||||||
{/if}
|
{#if sys.memory.swap_total_bytes > 0}
|
||||||
{/snippet}
|
<div
|
||||||
</KpiCard>
|
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb(
|
||||||
|
sys.memory.swap_total_bytes
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<span>{m.dashboard_swap({ swapPct })}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={swapPct} class={usageBar(swapPct)} />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</KpiCard>
|
||||||
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={m.dashboard_load_average()}
|
label={m.dashboard_load_average()}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
value={sys.load.load1.toFixed(2)}
|
value={sys.load.load1.toFixed(2)}
|
||||||
valueClass="tabular-nums {usageText(loadPct)}"
|
valueClass="tabular-nums {usageText(loadPct)}"
|
||||||
detail={m.dashboard_load_detail({
|
detail={m.dashboard_load_detail({
|
||||||
cores: sys.cpu.logical_cpus,
|
cores: sys.cpu.logical_cpus,
|
||||||
load15: sys.load.load15.toFixed(2),
|
load15: sys.load.load15.toFixed(2),
|
||||||
load5: sys.load.load5.toFixed(2),
|
load5: sys.load.load5.toFixed(2),
|
||||||
loadPct
|
loadPct
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={m.dashboard_uptime()}
|
label={m.dashboard_uptime()}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
value={uptime(sys.uptime_seconds)}
|
value={uptime(sys.uptime_seconds)}
|
||||||
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
|
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
|
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<StoragePanel items={sys.disks ?? []} />
|
<StoragePanel items={sys.disks ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CpuHeatmap
|
||||||
|
cpuUsage={sys.load.cpu_usage}
|
||||||
|
cpuModel={sys.cpu.model}
|
||||||
|
logicalCpus={sys.cpu.logical_cpus}
|
||||||
|
minMhz={sys.cpu.min_mhz}
|
||||||
|
maxMhz={sys.cpu.max_mhz}
|
||||||
|
currentMhz={sys.cpu.current_mhz}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CpuHeatmap
|
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
|
||||||
cpuUsage={sys.load.cpu_usage}
|
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
|
||||||
cpuModel={sys.cpu.model}
|
<NetworkPanel items={sys.network_interfaces ?? []} />
|
||||||
logicalCpus={sys.cpu.logical_cpus}
|
<TemperaturePanel items={sys.temperatures ?? []} />
|
||||||
minMhz={sys.cpu.min_mhz}
|
</div>
|
||||||
maxMhz={sys.cpu.max_mhz}
|
|
||||||
currentMhz={sys.cpu.current_mhz}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
|
<div class="px-4 py-2 pb-4">
|
||||||
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
|
<ActivityPanel items={log} />
|
||||||
<NetworkPanel items={sys.network_interfaces ?? []} />
|
</div>
|
||||||
<TemperaturePanel items={sys.temperatures ?? []} />
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-2 pb-4">
|
|
||||||
<ActivityPanel items={log} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Root bind:open={editOpen}>
|
<Dialog.Root bind:open={editOpen}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
@@ -297,7 +439,7 @@
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={sys.$db.id} />
|
<input type="hidden" name="id" value={raw.$db.id} />
|
||||||
<Field.Group>
|
<Field.Group>
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="edit-name-{formId}">{m.machine_name()}</Field.Label>
|
<Field.Label for="edit-name-{formId}">{m.machine_name()}</Field.Label>
|
||||||
@@ -305,7 +447,7 @@
|
|||||||
id="edit-name-{formId}"
|
id="edit-name-{formId}"
|
||||||
placeholder={m.machine_name_placeholder()}
|
placeholder={m.machine_name_placeholder()}
|
||||||
{...updateMachine.fields.name.as('text')}
|
{...updateMachine.fields.name.as('text')}
|
||||||
value={sys.$db.name}
|
value={raw.$db.name}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
{#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||||
@@ -318,7 +460,7 @@
|
|||||||
id="edit-address-{formId}"
|
id="edit-address-{formId}"
|
||||||
placeholder={m.machine_address_placeholder()}
|
placeholder={m.machine_address_placeholder()}
|
||||||
{...updateMachine.fields.address.as('text')}
|
{...updateMachine.fields.address.as('text')}
|
||||||
value={sys.$db.address}
|
value={raw.$db.address}
|
||||||
/>
|
/>
|
||||||
{#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
|
{#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
@@ -348,7 +490,7 @@
|
|||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
|
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
{m.machine_delete_confirm({ name: sys.$db.name ?? '' })}
|
{m.machine_delete_confirm({ name: raw.$db.name ?? '' })}
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
@@ -368,7 +510,7 @@
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={sys.$db.id} />
|
<input type="hidden" name="id" value={raw.$db.id} />
|
||||||
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
|
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
|
||||||
{m.delete()}
|
{m.delete()}
|
||||||
</AlertDialog.Action>
|
</AlertDialog.Action>
|
||||||
@@ -378,3 +520,27 @@
|
|||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
</svelte:boundary>
|
</svelte:boundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.custom-dash {
|
||||||
|
background-image: linear-gradient(to right, currentColor 50%, transparent 0%);
|
||||||
|
background-size: 10px 2px;
|
||||||
|
background-repeat: repeat-x;
|
||||||
|
animation: custom-flow 1s linear infinite;
|
||||||
|
}
|
||||||
|
.custom-dash-ok {
|
||||||
|
color: var(--color-emerald-500, #10b981);
|
||||||
|
}
|
||||||
|
.custom-dash-bad {
|
||||||
|
color: var(--destructive, #ef4444);
|
||||||
|
animation-duration: 2s;
|
||||||
|
}
|
||||||
|
@keyframes custom-flow {
|
||||||
|
from {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user