fix: some improvement and docs
This commit is contained in:
+186
-1
@@ -22,8 +22,10 @@
|
||||
"create_account": "Create account",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_architecture": "Architecture",
|
||||
"dashboard_auto_refresh": "Auto-refresh",
|
||||
"dashboard_ascending": "Ascending",
|
||||
"dashboard_clock": "Clock",
|
||||
"dashboard_col_actions": "Actions",
|
||||
"dashboard_col_free": "Free",
|
||||
"dashboard_col_ip": "IP",
|
||||
"dashboard_col_method": "Method",
|
||||
@@ -41,7 +43,12 @@
|
||||
"dashboard_cpu_detail": "{cores} cores · {current} ({min}–{max})",
|
||||
"dashboard_descending": "Descending",
|
||||
"dashboard_dns": "DNS",
|
||||
"dashboard_filter_display": "Display",
|
||||
"dashboard_filter_title": "Filter",
|
||||
"dashboard_free_of": "{free} free of {total}",
|
||||
"dashboard_gpu": "GPU",
|
||||
"dashboard_gpu_compute": "Compute",
|
||||
"dashboard_gpu_vram": "VRAM",
|
||||
"dashboard_hardware_clock": "Hardware clock",
|
||||
"dashboard_interval_10s": "Every 10s",
|
||||
"dashboard_interval_30s": "Every 30s",
|
||||
@@ -56,6 +63,7 @@
|
||||
"dashboard_local_time": "Local time",
|
||||
"dashboard_locale": "Locale",
|
||||
"dashboard_logical_cores": "{cores} logical cores",
|
||||
"dashboard_machines_description": "Manage all servers connected to this dashboard.",
|
||||
"dashboard_memory": "Memory",
|
||||
"dashboard_nameserver": "Nameserver",
|
||||
"dashboard_network": "Network",
|
||||
@@ -65,12 +73,14 @@
|
||||
"dashboard_nothing_to_show": "Nothing to show.",
|
||||
"dashboard_os": "OS",
|
||||
"dashboard_packages": "Packages",
|
||||
"dashboard_page_of": "Page {page} of {total}",
|
||||
"dashboard_pagination_info": "{start}–{end} of {total}",
|
||||
"dashboard_pause": "Pause auto-refresh",
|
||||
"dashboard_prev": "Prev",
|
||||
"dashboard_recent_activity": "Recent activity",
|
||||
"dashboard_refresh": "Refresh",
|
||||
"dashboard_resume": "Resume auto-refresh",
|
||||
"dashboard_rows_per_page": "Rows per page",
|
||||
"dashboard_search_placeholder": "Search",
|
||||
"dashboard_since": "since {bootTime}",
|
||||
"dashboard_status_down": "down",
|
||||
@@ -178,6 +188,60 @@
|
||||
"groups_supplementary_label": "Supplementary ({count})",
|
||||
"home": "Home",
|
||||
"invalid_reset_link": "This link is invalid or has expired.",
|
||||
"landing_cta_desc": "Deploy your own instance and manage your servers from any device.",
|
||||
"landing_cta_title": "Ready to take control?",
|
||||
"landing_features_dashboard": "Live performance metrics",
|
||||
"landing_features_dashboard_desc": "Inspect real-time CPU performance heatmaps, memory consumption metrics, network bandwidth usage, and storage capacity graphs.",
|
||||
"landing_features_networking": "Network administration",
|
||||
"landing_features_networking_desc": "Configure network settings dynamically. Review interfaces, inspect routing tables, modify local hosts files, and configure system DNS resolution.",
|
||||
"landing_features_services": "Systemd service control",
|
||||
"landing_features_services_desc": "Control systemd services directly from the browser. Start, stop, restart units, toggle boot behavior, and follow log streams.",
|
||||
"landing_features_storage": "Filesystem and storage",
|
||||
"landing_features_storage_desc": "Monitor storage devices and filesystems. Mount and unmount storage volumes, view disk partitions, and configure fstab details.",
|
||||
"landing_features_terminal": "Interactive terminal",
|
||||
"landing_features_terminal_desc": "Open secure terminal windows connected directly to target hosts. Features terminal multiplexing, size auto-fitting, and persistent sessions.",
|
||||
"landing_features_title": "Complete server control",
|
||||
"landing_features_desc": "No complex configuration files or rigid wrappers. Nadir exposes standard system abstractions directly, letting you perform everyday administration actions natively.",
|
||||
"landing_features_users": "User and group management",
|
||||
"landing_features_users_desc": "Manage system user accounts and group assignments. Edit user privileges, view memberships, and authenticate via secure PAM rules.",
|
||||
"landing_footer_docs": "Documentation",
|
||||
"landing_footer_gitea": "GiTea",
|
||||
"landing_footer_subtitle": "Self-hosted, open-source server management.",
|
||||
"landing_hero_cta_github": "View source",
|
||||
"landing_hero_cta_start": "Get started",
|
||||
"landing_hero_title": "Modern server administration, unified.",
|
||||
"landing_hero_tagline": "An open-source console and lightweight agent built to inspect, manage, and secure your Linux hosts from any browser. Simple architecture, complete control.",
|
||||
"landing_arch_title": "Dual architecture, simple deployment",
|
||||
"landing_arch_desc": "Nadir separates your presentation layer from target host runtimes. The frontend operates as a stateless console, and the lightweight Go agent manages your systems directly.",
|
||||
"landing_arch_frontend_title": "Nadir Web UI",
|
||||
"landing_arch_frontend_desc": "The central operations dashboard. Served as a stateless Docker container. Sign in, manage server associations, and monitor all nodes.",
|
||||
"landing_arch_backend_title": "Nadir Agent",
|
||||
"landing_arch_backend_desc": "The per-host service daemon. Lightweight binary compiled in Go. Runs as root under PAM authentication, providing local system access.",
|
||||
"landing_how_title": "Deploy in under two minutes",
|
||||
"landing_how_desc": "Follow these simple steps to run the Web UI and link your first target host.",
|
||||
"landing_how_step1_title": "Spin up the Web UI",
|
||||
"landing_how_step1_desc": "Deploy the stateless web dashboard using Docker. This interface will coordinate your registered agents. Access the web setup at port 3000 to configure your dashboard admin account.",
|
||||
"landing_how_step2_title": "Bootstrap the target server",
|
||||
"landing_how_step2_desc": "Run the installation script on the host machine you want to manage. The script automatically detects the host architecture (amd64/arm64), fetches the latest tagged binary release from Gitea, verifies SHA-256 integrity, configures a systemd unit, and installs a PAM configuration file.",
|
||||
"landing_security_note": "Security note: The bootstrap installer requires root privileges. It generates a self-signed TLS certificate automatically if secure TLS is configured.",
|
||||
"landing_security_architecture_title": "Deployment security and architecture",
|
||||
"landing_security_architecture_desc": "Because the nadir-agent operates with root privileges, it should never be exposed directly to the public internet. By default, it binds to localhost. It is designed to be co-located on the same server as the Web UI communicating over loopback, or securely accessed across nodes over a private VPN (such as WireGuard, Tailscale, or Netbird). While agent installations support generating self-signed TLS certificates automatically, operating within a trusted private network boundary remains the recommended deployment model.",
|
||||
"landing_how_step3_title": "Link in the dashboard",
|
||||
"landing_how_step3_desc": "When the agent installation completes, it outputs a dashboard association token. Copy this Bearer token, log into the Web UI dashboard, click \"Add Machine,\" enter the IP/address and token, and start administering your server.",
|
||||
"landing_screenshot_dashboard": "Machine dashboard with real-time performance metrics, CPU heatmap, memory usage, and network activity.",
|
||||
"landing_screenshot_hero_label": "Dashboard overview with real-time metrics",
|
||||
"landing_screenshot_machines": "Multi-server overview showing all connected machines with health status.",
|
||||
"landing_screenshot_services": "Service management table with start, stop, and restart controls for systemd units.",
|
||||
"landing_screenshots_title": "See it in action",
|
||||
"landing_start_code": "bun install && cp .env.example .env && bun run db:push && bun run dev",
|
||||
"landing_tech_linux": "Linux",
|
||||
"landing_tech_opensource": "Open Source",
|
||||
"landing_tech_selfhosted": "Self-Hosted",
|
||||
"landing_start_desc": "Prerequisites: Bun and a reachable nadir-agent instance.",
|
||||
"landing_start_title": "Get started in minutes",
|
||||
"landing_stats_linux": "Linux-native",
|
||||
"landing_stats_opensource": "Open source",
|
||||
"landing_stats_setup": "One-command setup",
|
||||
"login": "Login",
|
||||
"more": "More",
|
||||
"login_social_description": "You have to login to use this platform. Use your favorite social or your credentials",
|
||||
@@ -477,6 +541,7 @@
|
||||
"services_logs_autoscroll": "Autoscroll",
|
||||
"services_logs_clear": "Clear",
|
||||
"services_logs_empty": "No log entries.",
|
||||
"services_logs_filters": "Filters",
|
||||
"services_logs_lines": "Lines",
|
||||
"services_logs_live": "Live",
|
||||
"services_logs_live_streaming": "Streaming",
|
||||
@@ -545,6 +610,10 @@
|
||||
"system_power_confirm_reboot_title": "Reboot this machine?",
|
||||
"system_power_poweroff": "Power off",
|
||||
"system_power_reboot": "Reboot",
|
||||
"system_section_configuration": "Configuration",
|
||||
"system_section_identity": "Identity",
|
||||
"system_section_power": "Power",
|
||||
"system_time_clock": "Clock",
|
||||
"system_time_current": "Current time",
|
||||
"system_time_manual": "Manual time",
|
||||
"system_time_manual_hint": "Set the system clock to a specific RFC3339 time. Available only when NTP is off.",
|
||||
@@ -697,5 +766,121 @@
|
||||
"system_nadir_username": "Authenticated User",
|
||||
"system_nadir_permissions": "Resolved Permissions",
|
||||
"system_nadir_modules": "Registered Modules",
|
||||
"system_nadir_no_permissions": "No permissions defined"
|
||||
"system_nadir_no_permissions": "No permissions defined",
|
||||
"docs_title": "Docs",
|
||||
"docs_go_to_dashboard": "Go to Dashboard",
|
||||
"docs_nav_intro": "Introduction",
|
||||
"docs_nav_installation": "Installation",
|
||||
"docs_nav_architecture": "Architecture",
|
||||
"docs_nav_security": "Security & VPN",
|
||||
"docs_nav_limitations": "Limitations & License",
|
||||
"docs_intro_title": "Introduction",
|
||||
"docs_intro_desc": "Welcome to the Nadir documentation. Nadir is a lightweight, modular Linux system-administration suite that provides modern, self-hosted web management panels for your servers.",
|
||||
"docs_intro_how_title": "How it works",
|
||||
"docs_intro_how_desc": "Nadir is divided into two distinct parts: a central <strong>Web UI (Frontend)</strong> and a per-host <strong>Agent (Backend)</strong>. The frontend does not talk to your operating system directly; instead, it registers multiple agent nodes using secure API tokens and makes server-to-server HTTP queries to manage your systems.",
|
||||
"docs_intro_modules_title": "Core modules",
|
||||
"docs_intro_modules_desc": "Functionality is organized into isolated modules. Each module manages a slice of system configuration and defines its own role-based access control (RBAC) permission vocabulary.",
|
||||
"docs_intro_mod_system_desc": "Dashboard overview (OS, kernel, CPU, memory, load, uptime, temperatures), hostname configuration, and date/time/NTP settings.",
|
||||
"docs_intro_mod_services_desc": "List and inspect systemd units. Start, stop, restart, enable, or disable units, and stream journald logs live over SSE.",
|
||||
"docs_intro_mod_users_desc": "Manage local PAM/Unix user accounts and group assignments. Run useradd/groupadd utilities natively.",
|
||||
"docs_intro_mod_storage_desc": "Monitor storage devices, partition layouts, and active mounts. View and modify /etc/fstab configurations.",
|
||||
"docs_intro_mod_networking_desc": "Manage network interfaces, routing tables, and DNS settings. Supports temporary configuration and safety auto-rollback.",
|
||||
"docs_intro_mod_audit_desc": "Read-only, tamper-evident SQLite-backed audit log capturing who executed what, when, and the result.",
|
||||
"docs_intro_mod_audit_title": "Audit Trail",
|
||||
"docs_intro_ready_title": "Ready to deploy?",
|
||||
"docs_intro_ready_desc": "Proceed to the installation guide to spin up the Web UI and deploy the backend agent.",
|
||||
"docs_intro_ready_cta": "Get started",
|
||||
"docs_install_title": "Installation",
|
||||
"docs_install_desc": "Deploy Nadir in under two minutes. Run the central Web UI as a stateless Docker container and bootstrap agent nodes on target Linux hosts.",
|
||||
"docs_install_webui_title": "1. Deploy the Web UI",
|
||||
"docs_install_webui_desc": "The central dashboard operates as a stateless web server that registers and coordinates multiple node instances. Use Docker to spin it up in seconds:",
|
||||
"docs_install_webui_run": "Once the container is running, navigate to <code>http://localhost:3000</code> to register your administrator account.",
|
||||
"docs_install_agent_title": "2. Bootstrap agent nodes",
|
||||
"docs_install_agent_desc": "Install the <code>nadir-agent</code> backend daemon on any Linux machine you want to manage. Run the bootstrap script on the host:",
|
||||
"docs_install_disclaimer_title": "Disclaimer: Inspect Before Execution",
|
||||
"docs_install_disclaimer_desc": "Piping untrusted scripts directly from the internet into a root shell is extremely dangerous. You should <strong>always inspect and read the source code</strong> of any script before running it. Review the installer code served by this instance at <a href=\"https://{nadirHost}/install.sh\" target=\"_blank\" class=\"underline text-primary hover:text-primary/80 font-medium\">/install.sh</a> or download it to read first: <code class=\"bg-muted px-1.5 py-0.5 rounded text-foreground font-mono text-[10px]\">curl -fsSL https://{nadirHost}/install.sh</code>.",
|
||||
"docs_install_privilege_title": "Security & Privileges",
|
||||
"docs_install_privilege_desc": "The bootstrap script must run as root (via <code>sudo sh</code>). The agent requires root privileges because it executes PAM authorization, checks systemd state, mounts filesystems, and interacts with <code>/etc/shadow</code> and <code>/etc/hosts</code> directly.",
|
||||
"docs_install_tasks_intro": "The installer script performs the following tasks automatically:",
|
||||
"docs_install_task_1": "Detects the system processor architecture (<code>amd64</code> or <code>arm64</code>).",
|
||||
"docs_install_task_2": "Queries the configured Gitea repository API for the latest tagged binary release.",
|
||||
"docs_install_task_3": "Downloads the release asset and verifies its SHA-256 signature against the published checksum file.",
|
||||
"docs_install_task_4": "Installs the <code>nadir</code> executable to <code>/usr/local/bin/nadir</code>.",
|
||||
"docs_install_task_5": "Provisions the dedicated PAM service <code>/etc/pam.d/nadir</code> to enable password validation.",
|
||||
"docs_install_task_6": "Generates persistent self-signed TLS certificates under <code>/var/lib/nadir/tls</code> (if TLS is enabled).",
|
||||
"docs_install_task_7": "Creates and starts a systemd service unit (<code>nadir.service</code>) that starts on boot.",
|
||||
"docs_install_link_title": "3. Link nodes to dashboard",
|
||||
"docs_install_link_desc": "When the installer finishes, it generates a bearer token (starting with <code>nad_</code>) for dashboard authentication. Copy this token, log into the Web UI, click <strong>Add Server</strong>, enter the IP address and port, paste the token, and save.",
|
||||
"docs_install_link_manual": "To manually add additional authentication tokens on a host, run:",
|
||||
"docs_arch_title": "Architecture",
|
||||
"docs_arch_desc": "Nadir is engineered for high performance, isolation, and security. Learn about the technical implementation details that drive the Web UI and the Agent.",
|
||||
"docs_arch_bounds_title": "Technical boundaries",
|
||||
"docs_arch_bounds_desc": "The central Web UI and the individual agent instances interact over standard HTTPS connections using token-based authentication.",
|
||||
"docs_arch_card_webui_title": "Nadir Web UI (Frontend)",
|
||||
"docs_arch_card_webui_desc": "SvelteKit 2 + Svelte 5 console application",
|
||||
"docs_arch_card_webui_item1": "SQLite Database for machine lists and profiles",
|
||||
"docs_arch_card_webui_item2": "Better Auth session and security layers",
|
||||
"docs_arch_card_webui_item3": "Stateless deployment in Docker",
|
||||
"docs_arch_card_flow_title": "HTTPS / API Call",
|
||||
"docs_arch_card_flow_desc": "Bearer token auth",
|
||||
"docs_arch_card_agent_title": "Nadir Agent (Backend)",
|
||||
"docs_arch_card_agent_desc": "Lightweight daemon compiled in Go",
|
||||
"docs_arch_card_agent_item1": "Runs as root with PAM authentication + RBAC",
|
||||
"docs_arch_card_agent_item2": "Direct communication with systemd & packages",
|
||||
"docs_arch_card_agent_item3": "SQLite-backed local audit trail",
|
||||
"docs_arch_daemon_title": "Self-contained Go agent",
|
||||
"docs_arch_daemon_desc": "The agent daemon runs on each host machine as a systemd service. It is designed to be fully self-contained and does not require external databases, runtimes, or interpreters.",
|
||||
"docs_arch_daemon_desc2": "Instead of implementing custom system logic, the Go daemon acts as an API proxy over the host's native utilities:",
|
||||
"docs_arch_daemon_item1": "<strong>Systemd</strong> controls service operations via direct interactions with systemd units.",
|
||||
"docs_arch_daemon_item2": "<strong>PAM (Pluggable Authentication Modules)</strong> handles credentials and account validations.",
|
||||
"docs_arch_daemon_item3": "<strong>OS Utilities</strong> like <code>useradd</code>, <code>userdel</code>, <code>groupadd</code>, <code>groupdel</code>, <code>hostnamectl</code>, and filesystem tools perform account, host, and storage changes.",
|
||||
"docs_arch_daemon_item4": "<strong>Package Managers</strong> (APT on Debian/Ubuntu, DNF on RHEL/Fedora, Pacman on Arch) perform software package updates and upgrades, streaming outputs live using Server-Sent Events (SSE).",
|
||||
"docs_arch_pam_title": "Dedicated PAM integration",
|
||||
"docs_arch_pam_desc": "To authenticate human logins, the agent delegates password checks to PAM (<code>pam_unix</code>). Instead of using default system login services, the agent installs a dedicated, minimal PAM service file at <code>/etc/pam.d/nadir</code>:",
|
||||
"docs_arch_pam_desc2": "This specific setup prevents performance issues common with stock login services. For instance, on laptops, the default <code>system-auth</code> pulls in <code>pam_fprintd.so</code> which blocks password verification for up to 30 seconds while waiting for fingerprint swipes. A dedicated PAM service keeps login validation times down to milliseconds.",
|
||||
"docs_arch_minisign_title": "Secure auto-updates via Minisign",
|
||||
"docs_arch_minisign_desc": "To ensure agent updates are secure, the updater implements strict cryptographic signature verification:",
|
||||
"docs_arch_minisign_item1": "The agent queries the configured Gitea release API to fetch the latest tagged release.",
|
||||
"docs_arch_minisign_item2": "It downloads both the checksum list file (<code>sha256sums.txt</code>) and its signature file (<code>sha256sums.txt.minisig</code>).",
|
||||
"docs_arch_minisign_item3": "It verifies the signature file against the hardcoded Minisign public key (<code>minisign.pub</code>) compiled into the binary. This trust anchor cannot be changed without rebuilding the binary.",
|
||||
"docs_arch_minisign_item4": "If the signature checks out, the agent downloads the new binary, verifies its SHA-256 hash against the verified checksum file, atomically replaces the running binary at <code>/usr/local/bin/nadir</code>, and restarts the systemd unit.",
|
||||
"docs_security_title": "Security & VPN",
|
||||
"docs_security_desc": "Because Nadir executes system commands as root, maintaining strict network boundaries is the most critical element of a secure deployment.",
|
||||
"docs_security_warning_title": "Warning: Root Access Risks",
|
||||
"docs_security_warning_desc": "An instance of <code>nadir-agent</code> running on a host holds root privileges. Exposing the agent port directly to the public internet is extremely dangerous and strongly discouraged. By default, the agent binds to localhost to ensure it remains local-only.",
|
||||
"docs_security_deploy_title": "Suggested deployments",
|
||||
"docs_security_deploy_desc": "Choose one of these recommended deployment strategies to isolate your system administration ports:",
|
||||
"docs_security_deploy_coloc_title": "A. Co-Location (Loopback)",
|
||||
"docs_security_deploy_coloc_desc": "Install both the <strong>Nadir Web UI</strong> and the <strong>nadir-agent</strong> on the exact same virtual machine or server. Configure the agent to bind to <code>127.0.0.1:9999</code> and let the Web UI container access the local agent directly. This keeps the agent API completely unexposed to any external network interface.",
|
||||
"docs_security_deploy_vpn_title": "B. Private Overlay VPN (Recommended)",
|
||||
"docs_security_deploy_vpn_desc": "For managing multiple servers, configure an overlay VPN (such as <strong>Tailscale</strong>, <strong>Netbird</strong>, or <strong>WireGuard</strong>). Bind the <code>nadir-agent</code> instance only to the host's private overlay IP (e.g. <code>100.64.x.x</code>). This guarantees that only authenticated VPN peers can see the administration port.",
|
||||
"docs_security_acl_title": "Access control list (ACL) rules",
|
||||
"docs_security_acl_desc": "If you deploy over a private overlay network, restrict access so that only the central Web UI server is permitted to talk to the agent ports (tcp/9999):",
|
||||
"docs_security_acl_tailscale": "<strong>Tailscale:</strong> Add an ACL rule restricting target ports to the proxy tag:",
|
||||
"docs_security_acl_netbird": "<strong>Netbird:</strong> Setup an Access Control Policy permitting group <code>nadir-ui</code> to group <code>nadir-agent</code> on port <code>9999</code>, denying other peers.",
|
||||
"docs_security_acl_iptables": "<strong>Standard iptables:</strong> Reject all connections on the WireGuard interface except from the proxy peer:",
|
||||
"docs_security_tls_title": "TLS connection modes",
|
||||
"docs_security_tls_desc": "API keys, tokens, and session cookies must always travel over encrypted HTTPS connections. Secure the connection using one of three modes:",
|
||||
"docs_security_tls_proxy_desc": "Behind a reverse proxy (<code>trust_proxy: true</code>): A proxy like Traefik terminates TLS at the edge and forwards plaintext HTTP to the agent over a secure local network. In this setup, make sure the proxy overrides client-supplied <code>X-Forwarded-*</code> headers, and set <code>secure_tls: true</code> to ensure the session cookie keeps its Secure attribute.",
|
||||
"docs_security_tls_direct_desc": "Direct TLS termination (<code>tls_cert</code> + <code>tls_key</code>): Specify paths to a valid PEM certificate and key file in <code>config.yaml</code> to have the agent server terminate TLS directly.",
|
||||
"docs_security_tls_self_desc": "Self-signed certificate fallback (development only): If no proxy or certificates are configured, the agent generates an in-memory self-signed certificate valid for <code>localhost</code> for one year. This triggers browser security warnings and should never be used in production.",
|
||||
"docs_limits_title": "Limitations & License",
|
||||
"docs_limits_desc": "Review the current architectural limits of the agent and the open-source licensing terms of the Nadir project.",
|
||||
"docs_limits_sys_title": "System limitations",
|
||||
"docs_limits_sys_desc": "Because Nadir works directly with core operating system abstractions rather than virtualization layers, it is bound by the following environmental requirements:",
|
||||
"docs_limits_sys_item1": "<strong>Linux Only:</strong> The agent relies heavily on standard Linux utilities (<code>useradd</code>, <code>groupadd</code>, <code>shutdown</code>), kernel namespaces, and file systems under <code>/sys</code>, <code>/proc</code>, and <code>/etc</code>. It cannot run on macOS, Windows, or BSD distributions.",
|
||||
"docs_limits_sys_item2": "<strong>Systemd Dependency:</strong> The service management, boot status, and journal logging modules interact directly with <code>systemd</code> and <code>journald</code>. Runtimes using other init systems (like sysvinit, OpenRC, or Runit) are not supported.",
|
||||
"docs_limits_sys_item3": "<strong>Root Privileges Required:</strong> The agent must execute as <code>root</code>. The PAM authenticator needs read access to <code>/etc/shadow</code>, and managing hardware settings (hostname, network interfaces, disk mounts) is locked behind root level syscalls by the kernel.",
|
||||
"docs_limits_sys_item4": "<strong>Co-hosting CORS constraints:</strong> Running the Web UI cross-origin relative to target agents triggers browser CORS preflight checks. If you host the frontend on a different origin, you must utilize server-to-server dashboard API queries (default behavior of SvelteKit server load functions) or configure a reverse proxy to terminate both under a shared domain.",
|
||||
"docs_limits_license_title": "Licensing",
|
||||
"docs_limits_license_desc": "Nadir is free, open-source software published under the terms of the MIT License.",
|
||||
"docs_limits_license_mit_title": "MIT License",
|
||||
"docs_limits_license_mit_copyright": "Copyright (c) 2026 Urania",
|
||||
"docs_limits_license_mit_body1": "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:",
|
||||
"docs_limits_license_mit_body2": "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.",
|
||||
"docs_limits_license_mit_body3": "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.",
|
||||
"docs_limits_credits_title": "Credits & assets",
|
||||
"docs_limits_credits_desc": "Nadir's branding assets and icons are based on open-source packages:",
|
||||
"docs_limits_credits_item1": "<strong>Favicon:</strong> Recolored version of the <a class=\"text-primary hover:underline\" href=\"https://lucide.dev/icons/orbit\" target=\"_blank\" rel=\"noreferrer\">Orbit icon</a> from the Lucide project.",
|
||||
"docs_limits_credits_item2": "<strong>Icons:</strong> Visual markers across pages are provided by the <a class=\"text-primary hover:underline\" href=\"https://lucide.dev\" target=\"_blank\" rel=\"noreferrer\">Lucide icons library</a>, licensed under the ISC License."
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,7 +17,8 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = session.user;
|
||||
} else if (
|
||||
!event.url.pathname.startsWith('/auth') &&
|
||||
!event.url.pathname.startsWith('/api/auth')
|
||||
!event.url.pathname.startsWith('/api/auth') &&
|
||||
event.url.pathname !== '/install.sh'
|
||||
) {
|
||||
redirect(307, '/auth/sign-in');
|
||||
}
|
||||
@@ -39,6 +40,7 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
.filter(Boolean);
|
||||
if (!roles.includes('admin')) redirect(307, '/dashboard');
|
||||
}
|
||||
|
||||
return svelteKitHandler({ auth, building, event, resolve });
|
||||
};
|
||||
|
||||
|
||||
@@ -115,9 +115,7 @@
|
||||
open = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -36,10 +36,7 @@
|
||||
function stripAnsi(text: string): string {
|
||||
const E = String.fromCharCode(27);
|
||||
const B = String.fromCharCode(7);
|
||||
const control = new RegExp(
|
||||
`[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]`,
|
||||
'g'
|
||||
);
|
||||
const control = new RegExp(`[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]`, 'g');
|
||||
return text
|
||||
.replace(new RegExp(`${E}\\[[0-9;<=>?]*[a-zA-Z]`, 'g'), '')
|
||||
.replace(new RegExp(`${E}\\][0-9;]*[^${E}]*(?:${E}\\\\|${B})`, 'g'), '')
|
||||
@@ -121,9 +118,7 @@
|
||||
view = 'terminal';
|
||||
} catch (err) {
|
||||
view = 'form';
|
||||
toast.error(
|
||||
extractErrorMessage(err) ?? m.terminal_ssh_failed()
|
||||
);
|
||||
toast.error(extractErrorMessage(err) ?? m.terminal_ssh_failed());
|
||||
localStorage.removeItem(storageKey);
|
||||
} finally {
|
||||
loadingSaved = false;
|
||||
@@ -171,9 +166,7 @@
|
||||
view = 'terminal';
|
||||
} catch (err) {
|
||||
view = 'form';
|
||||
toast.error(
|
||||
extractErrorMessage(err) ?? m.terminal_ssh_failed()
|
||||
);
|
||||
toast.error(extractErrorMessage(err) ?? m.terminal_ssh_failed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,9 +272,7 @@
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
sessionEnded = true;
|
||||
toast.error(
|
||||
extractErrorMessage(err) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(err) ?? m.errors_generic());
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -311,11 +302,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={terminalState.open} onOpenChange={(o) => { if (!o && sessionActive) return; if (!o) handleClose(); }}>
|
||||
<Dialog.Content
|
||||
class="sm:max-w-2xl sm:min-w-max -h-[500px]!"
|
||||
showCloseButton={!sessionActive}
|
||||
<Dialog.Root
|
||||
bind:open={terminalState.open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o && sessionActive) return;
|
||||
if (!o) handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-2xl sm:min-w-max -h-[500px]!" showCloseButton={!sessionActive}>
|
||||
{#if view === 'form'}
|
||||
<Dialog.Header>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -333,12 +327,16 @@
|
||||
|
||||
<div class="grid gap-5 px-4 py-3">
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{m.terminal_section_connection()}</p>
|
||||
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{m.terminal_section_connection()}
|
||||
</p>
|
||||
<div class="grid grid-cols-5 gap-3">
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Label for="term-user" class="text-xs">{m.username()}</Label>
|
||||
<div class="relative">
|
||||
<UserIcon class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<UserIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
id="term-user"
|
||||
class="pl-8 h-9 text-sm"
|
||||
@@ -356,7 +354,9 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{m.terminal_section_auth()}</p>
|
||||
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{m.terminal_section_auth()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if hasSaved && !loadingSaved}
|
||||
@@ -389,13 +389,17 @@
|
||||
>
|
||||
<Tabs.List class="h-9 px-0">
|
||||
<Tabs.Trigger value="password" class="text-xs h-9 px-3">{m.password()}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="key" class="text-xs h-9 px-3">{m.terminal_auth_private_key()}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="key" class="text-xs h-9 px-3"
|
||||
>{m.terminal_auth_private_key()}</Tabs.Trigger
|
||||
>
|
||||
</Tabs.List>
|
||||
<div class="mt-3">
|
||||
<Tabs.Content value="password" class="space-y-1.5">
|
||||
<Label for="term-password" class="text-xs">{m.password()}</Label>
|
||||
<div class="relative">
|
||||
<KeyRoundIcon class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<KeyRoundIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
id="term-password"
|
||||
class="pl-8 h-9 text-sm"
|
||||
@@ -418,10 +422,7 @@
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="term-save"
|
||||
bind:checked={saveCredential}
|
||||
/>
|
||||
<Checkbox id="term-save" bind:checked={saveCredential} />
|
||||
<Label for="term-save" class="text-xs text-muted-foreground cursor-pointer">
|
||||
{m.terminal_remember_credential()}
|
||||
</Label>
|
||||
@@ -438,7 +439,6 @@
|
||||
{m.terminal_connect()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
{:else if view === 'connecting'}
|
||||
<div class="flex flex-col items-center justify-center gap-4 py-16 min-h-125">
|
||||
<Spinner class="size-6 text-muted-foreground" />
|
||||
@@ -449,7 +449,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="flex flex-col min-h-125">
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
@@ -469,20 +468,31 @@
|
||||
<div
|
||||
class="relative flex-1 min-h-0 w-full bg-zinc-950 rounded-b-lg border-x border-b border-zinc-800 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="w-full h-full p-1.5"
|
||||
></div>
|
||||
<div bind:this={containerElement} class="w-full h-full p-1.5"></div>
|
||||
{#if sessionEnded}
|
||||
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 border-t border-zinc-800 bg-zinc-950/90 px-3 py-2.5">
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 border-t border-zinc-800 bg-zinc-950/90 px-3 py-2.5"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{capturedHistory.length > 2_000_000 ? m.terminal_history_truncated() : m.terminal_session_ended()}
|
||||
{capturedHistory.length > 2_000_000
|
||||
? m.terminal_history_truncated()
|
||||
: m.terminal_session_ended()}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="ghost" class="h-7 text-xs px-2" onclick={() => handleClose()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 text-xs px-2"
|
||||
onclick={() => handleClose()}
|
||||
>
|
||||
{m.terminal_discard()}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" class="h-7 text-xs px-2" onclick={downloadHistory}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
class="h-7 text-xs px-2"
|
||||
onclick={downloadHistory}
|
||||
>
|
||||
{m.terminal_download_history()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
title={m.dashboard_recent_activity()}
|
||||
icon={History}
|
||||
{items}
|
||||
pageSize={10}
|
||||
pageSize={25}
|
||||
initialDir="desc"
|
||||
columns={[
|
||||
{ get: (e) => e.time, key: 'time', label: m.dashboard_col_time() },
|
||||
|
||||
@@ -84,12 +84,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(60px,1fr))] gap-2 w-full">
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-2 w-full">
|
||||
{#each cores as c (c.core)}
|
||||
<div
|
||||
class="{getCoreColorClass(
|
||||
c.usage_pct
|
||||
)} group/square aspect-square rounded-[2px] border border-border transition-all duration-150 hover:z-10 hover:border-2 hover:border-primary flex flex-col"
|
||||
)} group/square aspect-square rounded-md border border-border transition-all duration-150 hover:z-10 hover:border-2 hover:border-primary flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="group-hover/square:opacity-100 opacity-0 transition-all flex justify-center items-center gap-0 aspect-square"
|
||||
|
||||
@@ -70,9 +70,7 @@
|
||||
const pageSize = new PersistedState<number>(pageSizeKey, defaultPageSize);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(items.length / pageSize.current)));
|
||||
const paginated = $derived(
|
||||
items.slice((page - 1) * pageSize.current, page * pageSize.current)
|
||||
);
|
||||
const paginated = $derived(items.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
$effect(() => {
|
||||
if (page > totalPages) page = totalPages;
|
||||
|
||||
@@ -15,6 +15,20 @@ export function uptime(seconds: number) {
|
||||
// first IPv4 (no colon), address only
|
||||
export const ipv4 = (addrs: null | string[]) => addrs?.find((a) => !a.includes(':'))?.split('/')[0];
|
||||
|
||||
// Strip lspci noise from GPU model strings
|
||||
export function trimGpuName(name: string): string {
|
||||
return name
|
||||
.replace(/^VGA compatible controller \[\w+\]:\s*/, '')
|
||||
.replace(
|
||||
/^(?:Advanced Micro Devices, Inc\. \[AMD\/ATI\]|NVIDIA Corporation|Intel Corporation)\s*/,
|
||||
''
|
||||
)
|
||||
.replace(/\s*\[\w+:\w+\]\s*/g, ' ')
|
||||
.replace(/\s*\(rev\s+\w+\)\s*/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// TZ pinned to UTC so SSR (Node host TZ) and first client paint produce identical
|
||||
// strings — the locale comes from paraglide, so it matches whatever the user picked.
|
||||
// ponytail: swap timeZone to the browser's TZ post-mount if you want local time.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import Gpu from '@lucide/svelte/icons/gpu';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
import { gb, pct, trimGpuName, usageBar, usageText } from './format';
|
||||
|
||||
let {
|
||||
gpus
|
||||
}: {
|
||||
gpus: {
|
||||
device_id: string;
|
||||
driver: string;
|
||||
mem_utilization_pct: number;
|
||||
memory_total_bytes: number;
|
||||
memory_used_bytes: number;
|
||||
model: string;
|
||||
utilization_pct: number;
|
||||
vendor: string;
|
||||
}[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class="h-full">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
|
||||
>
|
||||
<Gpu class="size-4" />
|
||||
</span>
|
||||
<Card.Title class="leading-1">{m.dashboard_gpu()}</Card.Title>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col grow gap-0">
|
||||
{#each gpus as gpu, i (gpu.vendor + gpu.device_id)}
|
||||
{@const memPct = pct(gpu.memory_used_bytes, gpu.memory_total_bytes)}
|
||||
<div
|
||||
class="flex flex-col gap-1.5 {i > 0 ? 'border-border/60 border-t pt-3' : ''} {i <
|
||||
gpus.length - 1
|
||||
? 'pb-3'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span class="font-medium truncate dmax-w-56" title={gpu.model}
|
||||
>{trimGpuName(gpu.model)}</span
|
||||
>
|
||||
<span class="text-muted-foreground font-mono text-xs">{gpu.driver}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-muted-foreground">{m.dashboard_gpu_compute()}</span>
|
||||
<span class="tabular-nums font-medium {usageText(gpu.utilization_pct)}"
|
||||
>{gpu.utilization_pct}%</span
|
||||
>
|
||||
</div>
|
||||
<Progress value={gpu.utilization_pct} class={usageBar(gpu.utilization_pct)} />
|
||||
</div>
|
||||
{#if gpu.memory_total_bytes > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-muted-foreground">{m.dashboard_gpu_vram()}</span>
|
||||
<span class="tabular-nums font-medium {usageText(memPct)}"
|
||||
>{gb(gpu.memory_used_bytes)} / {gb(gpu.memory_total_bytes)}</span
|
||||
>
|
||||
</div>
|
||||
<Progress value={memPct} class={usageBar(memPct)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -16,6 +16,7 @@
|
||||
title={m.dashboard_storage()}
|
||||
icon={HardDrive}
|
||||
{items}
|
||||
pageSize={5}
|
||||
initialDir="desc"
|
||||
columns={[
|
||||
{ get: (d) => pct(d.used_bytes, d.total_bytes), key: 'usage', label: m.dashboard_col_usage() },
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
opts: { class: string; mono: boolean } = { class: '', mono: false }
|
||||
)}
|
||||
<div class="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span class="text-muted-foreground text-xs">{label}</span>
|
||||
<span class="text-muted-foreground text-xs truncate max-w-[30ch]" title={label}>{label}</span>
|
||||
<span
|
||||
class="truncate text-right {opts.mono ? 'font-mono text-xs' : 'font-medium'} {opts.class ??
|
||||
''}">{value}</span
|
||||
|
||||
@@ -8,19 +8,17 @@
|
||||
import { tempText } from './format';
|
||||
|
||||
let { items }: { items: Temp[] } = $props();
|
||||
|
||||
const numbered = $derived(items.map((t, i) => ({ ...t, n: i + 1 })));
|
||||
</script>
|
||||
|
||||
<DataPanel
|
||||
title={m.dashboard_temperatures()}
|
||||
icon={Thermometer}
|
||||
items={numbered}
|
||||
{items}
|
||||
initialDir="desc"
|
||||
pageSize={9}
|
||||
pageSize={7}
|
||||
columns={[
|
||||
{ get: (t) => t.celsius, key: 'temp', label: m.dashboard_col_temp() },
|
||||
{ get: (t) => t.label, key: 'sensor', label: m.dashboard_col_sensor() }
|
||||
{ get: (t) => t.chip, key: 'chip', label: m.dashboard_col_sensor() }
|
||||
]}
|
||||
>
|
||||
{#snippet row(temp, i)}
|
||||
@@ -29,8 +27,11 @@
|
||||
? 'border-border/60 border-t'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-muted-foreground">{temp.label} #{temp.n}</span>
|
||||
<span class="font-semibold tabular-nums {tempText(temp.celsius)}"
|
||||
<div class="flex items-baseline gap-1.5 min-w-0">
|
||||
<span class="text-muted-foreground font-mono text-xs">{temp.chip}</span>
|
||||
<span class="truncate">{temp.label}</span>
|
||||
</div>
|
||||
<span class="font-semibold tabular-nums shrink-0 {tempText(temp.celsius)}"
|
||||
>{temp.celsius.toFixed(1)} °C</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -30,4 +30,4 @@ export type SystemDetails = {
|
||||
updates: { count: number; manager: string } | null;
|
||||
};
|
||||
|
||||
export type Temp = { celsius: number; label: string };
|
||||
export type Temp = { celsius: number; chip: string; label: string };
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
bind:ref
|
||||
data-slot="field-label"
|
||||
class={cn(
|
||||
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit leading-snug',
|
||||
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
|
||||
className
|
||||
)}
|
||||
|
||||
+17
-1
@@ -31,6 +31,22 @@ const EnvSchema = v.object({
|
||||
SMTP_USER: v.pipe(v.optional(v.string()))
|
||||
});
|
||||
|
||||
const env = v.parse(EnvSchema, Bun.env);
|
||||
const env = (() => {
|
||||
const rawEnv = typeof Bun !== 'undefined' ? Bun.env : process.env;
|
||||
// Mock defaults during build analysis when environment variables are not injected
|
||||
const parsed = v.safeParse(EnvSchema, rawEnv);
|
||||
if (parsed.issues) {
|
||||
const fallbackEnv = {
|
||||
CRYPTO_SECRET:
|
||||
rawEnv.CRYPTO_SECRET || 'build_time_mock_crypto_secret_for_sveltekit_build_analysis_step',
|
||||
REPOSITORY_URL:
|
||||
rawEnv.REPOSITORY_URL ||
|
||||
'https://tea.urania.dev/api/v1/repos/urania/nadir-agent/releases/latest',
|
||||
...rawEnv
|
||||
};
|
||||
return v.parse(EnvSchema, fallbackEnv);
|
||||
}
|
||||
return parsed.output;
|
||||
})();
|
||||
|
||||
export { env };
|
||||
|
||||
@@ -71,6 +71,14 @@ export const machineHealth = query.batch(v.string(), async (machineIds) => {
|
||||
return (id: string) => online.get(id) ?? false;
|
||||
});
|
||||
|
||||
export const allMachines = query(async () => {
|
||||
const rows = await db
|
||||
.select({ address: machines.address, id: machines.id, name: machines.name })
|
||||
.from(machines)
|
||||
.orderBy(sql`${machines.order} IS NULL`, asc(machines.order), asc(machines.name));
|
||||
return rows;
|
||||
});
|
||||
|
||||
export const getMachine = query(v.string(), async (id) => {
|
||||
const row = await db
|
||||
.select({ address: machines.address, id: machines.id, name: machines.name })
|
||||
|
||||
@@ -77,7 +77,10 @@ export const streamPackageAction = query.live(
|
||||
}
|
||||
|
||||
for await (const chunk of parseSseStream(response)) {
|
||||
yield chunk as { data: { error: string; line: string; message: string; success: boolean }; event: string };
|
||||
yield chunk as {
|
||||
data: { error: string; line: string; message: string; success: boolean };
|
||||
event: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ export const setTime = command(
|
||||
export const systemHostname = query(v.string(), async (machineId) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { data, error: err } = await nadir.GET('/api/system/hostname');
|
||||
console.log(err)
|
||||
console.log(err);
|
||||
if (!data) throwNadirError(err);
|
||||
return data;
|
||||
});
|
||||
@@ -133,12 +133,15 @@ export const setLocale = command(
|
||||
}
|
||||
);
|
||||
|
||||
export const generateLocale = command(v.object({ locale: v.string(), machineId: v.string() }), async ({ locale, machineId }) => {
|
||||
export const generateLocale = command(
|
||||
v.object({ locale: v.string(), machineId: v.string() }),
|
||||
async ({ locale, machineId }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } });
|
||||
if (err) throwNadirError(err);
|
||||
await listLocales(machineId).refresh();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const getModules = query(v.string(), async (machineId) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
|
||||
@@ -4,15 +4,15 @@ import { v } from '$lib';
|
||||
import { env } from '$lib/const/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { db } from '$lib/server/db';
|
||||
import { machines } from '$lib/server/db/schema';
|
||||
import { decryptValue, encrypt } from '$lib/server/db/custom-types';
|
||||
import { machines } from '$lib/server/db/schema';
|
||||
import {
|
||||
closeSession,
|
||||
createSession,
|
||||
getSession,
|
||||
resizeSession,
|
||||
writeToSession,
|
||||
type SessionAuth
|
||||
type SessionAuth,
|
||||
writeToSession
|
||||
} from '$lib/server/terminal/session';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
@@ -48,9 +48,7 @@ export const openSshTerminal = command(
|
||||
}
|
||||
);
|
||||
|
||||
export const streamSshTerminal = query.live(
|
||||
v.string(),
|
||||
async function* (sessionId: string) {
|
||||
export const streamSshTerminal = query.live(v.string(), async function* (sessionId: string) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
yield { data: 'SSH connection failed or session expired', event: 'error' };
|
||||
@@ -76,8 +74,7 @@ export const streamSshTerminal = query.live(
|
||||
reader.releaseLock();
|
||||
closeSession(sessionId);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export const writeSshTerminal = command(
|
||||
v.object({ data: v.string(), sessionId: v.string() }),
|
||||
@@ -100,16 +97,13 @@ export const closeSshTerminal = command(v.string(), async (sessionId: string) =>
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
export const encryptCredential = command(
|
||||
v.object({ data: v.string() }),
|
||||
async ({ data }) => {
|
||||
export const encryptCredential = command(v.object({ data: v.string() }), async ({ data }) => {
|
||||
const {
|
||||
locals: { user }
|
||||
} = getRequestEvent();
|
||||
if (!user) error(401, { message: m.errors_unauthenticated() });
|
||||
return { encrypted: encrypt(data, env.CRYPTO_SECRET) };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export const decryptCredential = command(
|
||||
v.object({ encrypted: v.string() }),
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
import { auth } from '$lib/auth/server';
|
||||
import { env } from '$lib/const/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { extractErrorMessage } from './utils';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
session as sessionTable,
|
||||
user as userTable,
|
||||
verification as verificationTable
|
||||
} from '$lib/server/db/auth-schema';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { and, asc, count, desc, eq, gt, gte, inArray, like, lte, max, or, sql } from 'drizzle-orm';
|
||||
|
||||
type Role = 'admin' | 'user';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { decryptValue } from '$lib/server/db/custom-types';
|
||||
import { getClient } from '$lib/server/nadir-agent/client';
|
||||
|
||||
export type AgentError = {
|
||||
body?: { code?: string; message?: string; };
|
||||
body?: { code?: string; message?: string };
|
||||
detail?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
@@ -15,7 +15,6 @@ export type Package = { name: string; version: string };
|
||||
|
||||
export type PackageList = { manager: string; packages: Package[] };
|
||||
|
||||
|
||||
export const nadirForMachine = async (machineId: string) => {
|
||||
const {
|
||||
locals: { user }
|
||||
@@ -107,7 +106,9 @@ export async function* parseSseStream(response: Response): AsyncGenerator<SseEve
|
||||
}
|
||||
}
|
||||
|
||||
export function throwNadirError(err: { detail?: string; status?: number; } | null | undefined): never {
|
||||
export function throwNadirError(
|
||||
err: { detail?: string; status?: number } | null | undefined
|
||||
): never {
|
||||
const status = err?.status || 500;
|
||||
let message = err?.detail;
|
||||
if (status === 403 || message === 'forbidden') {
|
||||
|
||||
@@ -10,6 +10,17 @@ export function decryptValue(encryptedValue: null | string | undefined): null |
|
||||
return decrypt(encryptedValue, env.CRYPTO_SECRET);
|
||||
}
|
||||
|
||||
export function encrypt(value: string, secret: string) {
|
||||
const key = getKey(secret);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
|
||||
return Buffer.concat([iv, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
export function encryptedText(name: string, options: { autoDecrypt: boolean }) {
|
||||
const { autoDecrypt } = options;
|
||||
|
||||
@@ -49,17 +60,6 @@ function decrypt(value: string, secret: string) {
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
export function encrypt(value: string, secret: string) {
|
||||
const key = getKey(secret);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
|
||||
return Buffer.concat([iv, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
function getKey(secret: string) {
|
||||
return crypto.createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
+4867
-4874
File diff suppressed because it is too large
Load Diff
@@ -167,15 +167,27 @@ export function writeToSession(sessionId: string, data: string): void {
|
||||
|
||||
function safeClose(ctrl: null | ReadableStreamDefaultController<string>) {
|
||||
if (!ctrl) return;
|
||||
try { ctrl.close(); } catch { /* controller already closed */ }
|
||||
try {
|
||||
ctrl.close();
|
||||
} catch {
|
||||
/* controller already closed */
|
||||
}
|
||||
}
|
||||
|
||||
function safeEnqueue(ctrl: null | ReadableStreamDefaultController<string>, data: string) {
|
||||
if (!ctrl) return;
|
||||
try { ctrl.enqueue(data); } catch { /* controller already closed */ }
|
||||
try {
|
||||
ctrl.enqueue(data);
|
||||
} catch {
|
||||
/* controller already closed */
|
||||
}
|
||||
}
|
||||
|
||||
function safeError(ctrl: null | ReadableStreamDefaultController<string>, err: Error) {
|
||||
if (!ctrl) return;
|
||||
try { ctrl.error(err); } catch { /* controller already errored */ }
|
||||
try {
|
||||
ctrl.error(err);
|
||||
} catch {
|
||||
/* controller already errored */
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -14,9 +14,20 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
|
||||
export function extractErrorMessage(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
const a = err as AgentError;
|
||||
return a.body?.message ?? a.message ?? a.detail ?? undefined;
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
moduleId: string,
|
||||
perm: 'read' | 'root' | 'write',
|
||||
userPerms: null | Record<string, null | string[]> | undefined
|
||||
): boolean {
|
||||
if (!userPerms) return false;
|
||||
if (userPerms['*']) return true;
|
||||
const modPerms = userPerms[moduleId];
|
||||
if (!modPerms) return false;
|
||||
return modPerms.includes('*') || modPerms.includes(perm);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta {title} {description} noIndex />
|
||||
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-6">
|
||||
<div class="m-auto flex w-full max-w-2xl flex-col gap-4 p-6">
|
||||
<Empty.Root class="border">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon" class="size-12">
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
|
||||
let { children } = $props();
|
||||
const user = $derived(getUser());
|
||||
const showSidebar =
|
||||
(cU: null | User) =>
|
||||
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
|
||||
const showSidebar = (cU: null | User) =>
|
||||
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'));
|
||||
|
||||
class TerminalState {
|
||||
open = $state(false);
|
||||
|
||||
+455
-3
@@ -1,8 +1,460 @@
|
||||
<script lang="ts">
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
||||
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import Orbit from '@lucide/svelte/icons/orbit';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import TerminalIcon from '@lucide/svelte/icons/terminal';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { resolve } from '$app/paths';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { CopyButton } from '$lib/components/ui/copy-button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getLocale, locales, setLocale } from '$lib/paraglide/runtime';
|
||||
import { setMode, userPrefersMode } from 'mode-watcher';
|
||||
|
||||
const current = $derived(getLocale());
|
||||
const names = $derived(new Intl.DisplayNames([current], { type: 'language' }));
|
||||
const labelOf = (l: string) => names.of(l) ?? l;
|
||||
|
||||
// Resolve PUBLIC_NADIR_URL with default fallback
|
||||
const nadirHost = $derived(env.PUBLIC_NADIR_URL || 'nadir.urania.dev');
|
||||
const installCommand = $derived(`curl -fsSL https://${nadirHost}/install.sh | sudo sh`);
|
||||
const dockerCommand = 'docker run -d -p 3000:3000 urania/nadir-frontend';
|
||||
|
||||
const features = $derived([
|
||||
{
|
||||
desc: m.landing_features_dashboard_desc(),
|
||||
icon: CpuIcon,
|
||||
title: m.landing_features_dashboard()
|
||||
},
|
||||
{
|
||||
desc: m.landing_features_services_desc(),
|
||||
icon: ServerIcon,
|
||||
title: m.landing_features_services()
|
||||
},
|
||||
{
|
||||
desc: m.landing_features_users_desc(),
|
||||
icon: UsersIcon,
|
||||
title: m.landing_features_users()
|
||||
},
|
||||
{
|
||||
desc: m.landing_features_networking_desc(),
|
||||
icon: GlobeIcon,
|
||||
title: m.landing_features_networking()
|
||||
},
|
||||
{
|
||||
desc: m.landing_features_storage_desc(),
|
||||
icon: HardDrive,
|
||||
title: m.landing_features_storage()
|
||||
},
|
||||
{
|
||||
desc: m.landing_features_terminal_desc(),
|
||||
icon: TerminalIcon,
|
||||
title: m.landing_features_terminal()
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_root()} description={m.seo_desc_root()} noIndex />
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<PageMeta title={m.seo_title_root()} description={m.seo_desc_root()} />
|
||||
|
||||
<!-- Top Navigation -->
|
||||
<header
|
||||
class="bg-background/80 border-b border-border/60 fixed top-0 z-50 flex w-full items-center gap-4 px-6 py-4 backdrop-blur-md"
|
||||
>
|
||||
<a href={resolve('/')} class="flex items-center gap-2.5 font-semibold text-foreground">
|
||||
<Orbit class="text-primary size-5 animate-pulse" />
|
||||
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
|
||||
</a>
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<a href={resolve('/auth/sign-in')} class={buttonVariants({ size: 'sm', variant: 'ghost' })}>
|
||||
{m.login()}
|
||||
</a>
|
||||
<a href={resolve('/auth/sign-up')} class={buttonVariants({ size: 'sm', variant: 'default' })}>
|
||||
{m.landing_hero_cta_start()}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="min-h-screen pt-16 text-foreground antialiased selection:bg-primary/20">
|
||||
<!-- Hero Section -->
|
||||
<section class="border-b border-border bg-linear-to-b from-background to-muted/20">
|
||||
<div class="mx-auto max-w-6xl px-6 py-20 md:py-28">
|
||||
<div class="grid grid-cols-1 gap-12 md:grid-cols-2 md:items-center">
|
||||
<div class="flex flex-col items-start text-left">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="mb-5 border-primary/30 px-3 py-1 font-mono text-xs text-primary shadow-sm"
|
||||
>
|
||||
{m.landing_tech_opensource()} • {m.landing_tech_selfhosted()}
|
||||
</Badge>
|
||||
<h1
|
||||
class="mb-4 text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl text-balance leading-none"
|
||||
>
|
||||
{m.landing_hero_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mb-8 max-w-lg text-lg leading-relaxed text-pretty">
|
||||
{m.landing_hero_tagline()}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="#get-started" class={buttonVariants({ size: 'lg' })}>
|
||||
{m.landing_hero_cta_start()}
|
||||
</a>
|
||||
<a
|
||||
href="https://tea.urania.dev/urania/nadir-webui"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class={buttonVariants({ size: 'lg', variant: 'outline' })}
|
||||
>
|
||||
{m.landing_hero_cta_github()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Dominant Hook (Editorial Mock Dashboard) -->
|
||||
<div
|
||||
class="relative w-full min-w-max rounded-xl border border-border bg-background/50 p-4 shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 border-b border-border pb-3 mb-4">
|
||||
<span class="size-3 rounded-full bg-red-500/80"></span>
|
||||
<span class="size-3 rounded-full bg-yellow-500/80"></span>
|
||||
<span class="size-3 rounded-full bg-green-500/80"></span>
|
||||
<span class="text-xs text-muted-foreground font-mono ml-2"
|
||||
>nadir-webui — dashboard</span
|
||||
>
|
||||
</div>
|
||||
<!-- Preview Stack of KPI Cards -->
|
||||
<div
|
||||
class="relative h-42 w-full mb-4 flex items-center justify-center select-none overflow-visible"
|
||||
>
|
||||
<!-- Card.Root 4 (Uptime) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[-75px] -translate-y-3 opacity-40 origin-center pointer-events-none z-10"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_uptime()}
|
||||
icon={ClockIcon}
|
||||
value="142d 18h"
|
||||
detail="since boot"
|
||||
/>
|
||||
</div>
|
||||
<!-- Card.Root 3 (Load Average) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[-25px] -translate-y-1 opacity-95 origin-center pointer-events-none z-20"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_load_average()}
|
||||
icon={ActivityIcon}
|
||||
value="4.12"
|
||||
detail="64 logical cores"
|
||||
/>
|
||||
</div>
|
||||
<!-- Card.Root 2 (Memory) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[25px] translate-y-1 opacity-97 origin-center pointer-events-none z-30"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_memory()}
|
||||
icon={MemoryStickIcon}
|
||||
value="25.6%"
|
||||
detail="32.8 / 128 GB"
|
||||
/>
|
||||
</div>
|
||||
<!-- Card.Root 1 (CPU) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[75px] translate-y-3 opacity-100 origin-center shadow-2xl z-40"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_cpu()}
|
||||
icon={CpuIcon}
|
||||
value="2.80 GHz"
|
||||
detail="AMD EPYC 7763"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border border-border min-w-max rounded-lg overflow-hidden bg-black/90 p-4 text-xs font-mono text-emerald-400"
|
||||
>
|
||||
<div class="text-muted-foreground mb-1">// Querying nadir-agent metrics...</div>
|
||||
<div>$ curl -fsSL https://{nadirHost}/api/health</div>
|
||||
<div class="text-white mt-1 whitespace-nowrap">
|
||||
{ "status": "healthy", "version": "v1.2.0", "uptime": "142d 18h" }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dual-Architecture Fused Presentation -->
|
||||
<section class="border-b border-border bg-background py-16 md:py-24" id="get-started">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="mb-16 max-w-2xl text-balance text-left">
|
||||
<h2 class="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
|
||||
{m.landing_arch_title()}
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg text-pretty">
|
||||
{m.landing_arch_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Frontend Card.Root -->
|
||||
<Card.Root class="border-border bg-muted/5 flex flex-col justify-between overflow-hidden">
|
||||
<Card.Header class="pb-4">
|
||||
<Badge class="w-fit mb-2" variant="secondary">Frontend</Badge>
|
||||
<Card.Title class="text-xl">{m.landing_arch_frontend_title()}</Card.Title>
|
||||
<Card.Description class="text-pretty">
|
||||
{m.landing_arch_frontend_desc()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<div
|
||||
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">{dockerCommand}</span>
|
||||
<CopyButton text={dockerCommand} size="sm" variant="ghost" />
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Backend Card.Root -->
|
||||
<Card.Root class="border-border bg-muted/5 flex flex-col justify-between overflow-hidden">
|
||||
<Card.Header class="pb-4">
|
||||
<Badge class="w-fit mb-2" variant="secondary">Backend</Badge>
|
||||
<Card.Title class="text-xl">{m.landing_arch_backend_title()}</Card.Title>
|
||||
<Card.Description class="text-pretty">
|
||||
{m.landing_arch_backend_desc()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<div
|
||||
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">{installCommand}</span>
|
||||
<CopyButton text={installCommand} size="sm" variant="ghost" />
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Security Architecture Note -->
|
||||
<div
|
||||
class="mt-8 border border-amber-500/20 bg-amber-500/5 rounded-xl p-6 text-sm leading-relaxed text-pretty"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 text-amber-600/95 font-bold mb-2 font-mono text-xs uppercase tracking-wider"
|
||||
>
|
||||
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500" />
|
||||
<span>{m.landing_security_architecture_title()}</span>
|
||||
</div>
|
||||
<p class="text-muted-foreground">{m.landing_security_architecture_desc()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<section class="border-b border-border bg-muted/10 py-16 md:py-24">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="mb-16 max-w-2xl text-left">
|
||||
<h2 class="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
|
||||
{m.landing_features_title()}
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg text-pretty">
|
||||
{m.landing_features_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each features as feature (feature.title)}
|
||||
<div
|
||||
class="border border-border/80 bg-background rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-primary">
|
||||
<feature.icon class="size-5 shrink-0" stroke-width="2" />
|
||||
<h3 class="font-bold text-foreground text-base tracking-tight">{feature.title}</h3>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">{feature.desc}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quickstart Walkthrough -->
|
||||
<section class="py-16 md:py-24 bg-background">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="mb-16 text-left">
|
||||
<h2 class="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
|
||||
{m.landing_how_title()}
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg text-pretty">
|
||||
{m.landing_how_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-12">
|
||||
<!-- Step 1 -->
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-mono font-bold text-base shadow-md"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-1">{m.landing_how_step1_title()}</h3>
|
||||
<p class="text-muted-foreground text-sm text-pretty leading-relaxed">
|
||||
{m.landing_how_step1_desc()}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">{dockerCommand}</span>
|
||||
<CopyButton text={dockerCommand} size="sm" variant="ghost" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-mono font-bold text-base shadow-md"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-1">{m.landing_how_step2_title()}</h3>
|
||||
<p class="text-muted-foreground text-sm text-pretty leading-relaxed">
|
||||
{m.landing_how_step2_desc()}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">{installCommand}</span>
|
||||
<CopyButton text={installCommand} size="sm" variant="ghost" />
|
||||
</div>
|
||||
<div
|
||||
class="flex items-start gap-2 border border-amber-500/20 bg-amber-500/5 rounded-lg p-3 text-xs text-amber-600/90 leading-relaxed font-mono"
|
||||
>
|
||||
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500 mt-0.5" />
|
||||
<div>
|
||||
<strong>Security note:</strong>
|
||||
{m.landing_security_note().replace('Security note:', '').trim()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-mono font-bold text-base shadow-md"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold text-lg mb-1">{m.landing_how_step3_title()}</h3>
|
||||
<p class="text-muted-foreground text-sm text-pretty leading-relaxed">
|
||||
{m.landing_how_step3_desc()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-muted border-t border-border py-12">
|
||||
<div class="mx-auto flex max-w-6xl flex-col items-center gap-6 px-6">
|
||||
<a href={resolve('/')} class="flex items-center gap-2 font-semibold">
|
||||
<Orbit class="text-primary size-5" />
|
||||
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
|
||||
</a>
|
||||
<p class="text-muted-foreground text-balance text-center text-sm max-w-md">
|
||||
{m.landing_footer_subtitle()}
|
||||
</p>
|
||||
<nav class="flex items-center gap-6 text-sm">
|
||||
<a
|
||||
href="https://tea.urania.dev/urania/nadir-webui"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{m.landing_footer_gitea()}
|
||||
</a>
|
||||
<a
|
||||
href="https://tea.urania.dev/urania/nadir-agent"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{m.docs_arch_card_agent_title()}
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/docs')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{m.landing_footer_docs()}
|
||||
</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5 text-muted-foreground"
|
||||
onclick={() => {
|
||||
const all = locales;
|
||||
const cur = current ?? all[0];
|
||||
const idx = all.indexOf(cur);
|
||||
if (idx < 0) return;
|
||||
const next = all[(idx + 1) % all.length];
|
||||
if (next) setLocale(next);
|
||||
}}
|
||||
>
|
||||
<GlobeIcon class="size-4" />
|
||||
<span>{labelOf(current)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label={m.theme()}
|
||||
onclick={() =>
|
||||
setMode(
|
||||
userPrefersMode.current === 'light'
|
||||
? 'dark'
|
||||
: userPrefersMode.current === 'dark'
|
||||
? 'system'
|
||||
: 'light'
|
||||
)}
|
||||
>
|
||||
{#if userPrefersMode.current === 'light'}
|
||||
<SunIcon class="size-4" />
|
||||
{:else if userPrefersMode.current === 'dark'}
|
||||
<MoonIcon class="size-4" />
|
||||
{:else}
|
||||
<MonitorIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if requestReset.result?.sent}
|
||||
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
<Button href={resolve('/auth/sign-in')} variant="outline" class="w-full"
|
||||
>{m.back_to_login()}</Button
|
||||
>
|
||||
{:else}
|
||||
<form
|
||||
oninput={() => requestReset.validate()}
|
||||
@@ -37,9 +39,7 @@
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -36,9 +36,7 @@
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -36,9 +36,7 @@
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Footer>
|
||||
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
<Button href={resolve('/auth/sign-in')} variant="outline" class="w-full"
|
||||
>{m.back_to_login()}</Button
|
||||
>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
@@ -41,9 +43,7 @@
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,366 @@
|
||||
<script lang="ts">
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { resolve } from '$app/paths';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { machineDeleteSchema, machineEditSchema, machineSchema } from '$lib/machines/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
addMachine,
|
||||
allMachines,
|
||||
deleteMachine,
|
||||
listMachines,
|
||||
machineHealth,
|
||||
updateMachine
|
||||
} from '$lib/remotes/machines.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type Machine = { address: string; id: string; name: null | string };
|
||||
type SortBy = 'address' | 'name';
|
||||
type Dir = 'asc' | 'desc';
|
||||
|
||||
const id = $props.id();
|
||||
|
||||
const machines = $derived(allMachines());
|
||||
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
|
||||
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('machines.sort', {
|
||||
sortBy: 'name',
|
||||
sortDir: 'asc'
|
||||
});
|
||||
let sortBy = $state<SortBy>(sortStore.current.sortBy);
|
||||
let sortDir = $state<Dir>(sortStore.current.sortDir);
|
||||
$effect(() => {
|
||||
sortStore.current = { sortBy, sortDir };
|
||||
});
|
||||
|
||||
let page = $state(1);
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggleSort(col: SortBy) {
|
||||
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
sortBy = col;
|
||||
sortDir = 'asc';
|
||||
}
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const all = (machines.current ?? []) as Machine[];
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
const out = q
|
||||
? all.filter((m) => m.name?.toLowerCase().includes(q) || m.address.toLowerCase().includes(q))
|
||||
: all;
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
out.sort((a, b) => {
|
||||
const av = a[sortBy] ?? '';
|
||||
const bv = b[sortBy] ?? '';
|
||||
return av < bv ? -1 * dir : av > bv ? 1 * dir : 0;
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
let addOpen = $state(false);
|
||||
|
||||
let editingMachine = $state<Machine | null>(null);
|
||||
let editOpen = $state(false);
|
||||
|
||||
let deletingMachine = $state<Machine | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
async function afterMutate() {
|
||||
allMachines().refresh();
|
||||
listMachines({ page: 1, search: '' }).refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_dashboard()} description={m.seo_desc_dashboard()} />
|
||||
<DataTable
|
||||
title={m.dashboard()}
|
||||
description={m.dashboard_machines_description()}
|
||||
searchPlaceholder={m.machine_search_placeholder()}
|
||||
emptyMessage={m.machine_none()}
|
||||
items={filtered}
|
||||
loading={machines.loading}
|
||||
pageSizeKey="machines.pageSize"
|
||||
defaultPageSize={25}
|
||||
pageSizePresets={[10, 25, 50, 100]}
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => machines.refresh()}
|
||||
bind:page
|
||||
i18n={{
|
||||
display: m.dashboard_filter_display(),
|
||||
filterTitle: m.dashboard_filter_title(),
|
||||
next: () => m.dashboard_next(),
|
||||
pageOf: (p) => m.dashboard_page_of(p),
|
||||
previous: () => m.dashboard_prev(),
|
||||
rowsPerPage: m.dashboard_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (addOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.machine_add()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
{#each [{ key: 'name', label: m.machine_name() }, { key: 'address', label: m.machine_address() }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.dashboard_col_status()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.dashboard_col_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(machine: Machine)}
|
||||
{@const health = machineHealth(machine.id).current}
|
||||
<Table.Cell class="font-medium">
|
||||
<a href={resolve('/dashboard/[machineId]', { machineId: machine.id })} class="hover:underline"
|
||||
>{machine.name ?? machine.id.slice(0, 8)}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{machine.address}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs font-medium {health
|
||||
? 'text-emerald-500'
|
||||
: 'text-destructive'}"
|
||||
>
|
||||
<span class="size-1.5 rounded-full {health ? 'bg-emerald-500' : 'bg-destructive'}"></span>
|
||||
{health ? m.machine_online() : m.machine_offline()}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
editingMachine = machine;
|
||||
editOpen = true;
|
||||
}}>{m.machine_edit()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deletingMachine = machine;
|
||||
deleteOpen = true;
|
||||
}}>{m.delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Dialog.Root bind:open={addOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.machine_add()}</Dialog.Title>
|
||||
<Dialog.Description>{m.machine_add_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
oninput={() => addMachine.validate()}
|
||||
{...addMachine.preflight(machineSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.machine_add());
|
||||
addOpen = false;
|
||||
await afterMutate();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="add-name-{id}">{m.machine_name()}</Field.Label>
|
||||
<Input
|
||||
id="add-name-{id}"
|
||||
placeholder={m.machine_name_placeholder()}
|
||||
{...addMachine.fields.name.as('text')}
|
||||
required
|
||||
/>
|
||||
{#each addMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="add-address-{id}">{m.machine_address()}</Field.Label>
|
||||
<Input
|
||||
id="add-address-{id}"
|
||||
placeholder={m.machine_address_placeholder()}
|
||||
{...addMachine.fields.address.as('text')}
|
||||
/>
|
||||
{#each addMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="add-token-{id}">{m.machine_token()}</Field.Label>
|
||||
<Input
|
||||
id="add-token-{id}"
|
||||
placeholder={m.machine_token_placeholder()}
|
||||
{...addMachine.fields.token.as('password')}
|
||||
required
|
||||
/>
|
||||
{#each addMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={!!addMachine.pending}>{m.machine_save()}</Button>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
{#if editingMachine}
|
||||
{@const editForm = updateMachine.for(editingMachine.id)}
|
||||
<Dialog.Root bind:open={editOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.machine_edit()}</Dialog.Title>
|
||||
<Dialog.Description>{m.machine_edit_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
oninput={() => editForm.validate()}
|
||||
{...editForm.preflight(machineEditSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.machine_edit());
|
||||
editOpen = false;
|
||||
editingMachine = null;
|
||||
await afterMutate();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input type="hidden" name="id" value={editingMachine.id} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="edit-name-{id}">{m.machine_name()}</Field.Label>
|
||||
<Input
|
||||
id="edit-name-{id}"
|
||||
placeholder={m.machine_name_placeholder()}
|
||||
{...editForm.fields.name.as('text')}
|
||||
value={editingMachine.name ?? ''}
|
||||
required
|
||||
/>
|
||||
{#each editForm.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="edit-address-{id}">{m.machine_address()}</Field.Label>
|
||||
<Input
|
||||
id="edit-address-{id}"
|
||||
placeholder={m.machine_address_placeholder()}
|
||||
{...editForm.fields.address.as('text')}
|
||||
value={editingMachine.address}
|
||||
/>
|
||||
{#each editForm.fields.address.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="edit-token-{id}">{m.machine_token()}</Field.Label>
|
||||
<Input
|
||||
id="edit-token-{id}"
|
||||
placeholder={m.machine_token_placeholder()}
|
||||
{...editForm.fields.token.as('password')}
|
||||
/>
|
||||
<Field.Description>{m.machine_token_keep()}</Field.Description>
|
||||
{#each editForm.fields.token.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={!!editForm.pending}>{m.machine_save_edit()}</Button>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
{m.machine_delete_confirm({ name: deletingMachine?.name ?? '' })}
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<form
|
||||
{...deleteMachine.preflight(machineDeleteSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.machine_delete_title());
|
||||
deleteOpen = false;
|
||||
deletingMachine = null;
|
||||
await afterMutate();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input type="hidden" name="id" value={deletingMachine?.id} />
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
|
||||
{m.delete()}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</form>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
import Ellipsis from '@lucide/svelte/icons/ellipsis';
|
||||
import Globe from '@lucide/svelte/icons/globe';
|
||||
import MemoryStick from '@lucide/svelte/icons/memory-stick';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
@@ -23,11 +21,13 @@
|
||||
import {
|
||||
fmtDateTime,
|
||||
gb,
|
||||
ghz,
|
||||
pct,
|
||||
uptime,
|
||||
usageBar,
|
||||
usageText
|
||||
} from '$lib/components/dashboard/format';
|
||||
import GpuCard from '$lib/components/dashboard/gpu-card.svelte';
|
||||
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
|
||||
import NetworkPanel from '$lib/components/dashboard/network-panel.svelte';
|
||||
import StoragePanel from '$lib/components/dashboard/storage-panel.svelte';
|
||||
@@ -36,13 +36,14 @@
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ButtonGroup } from '$lib/components/ui/button-group';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
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 { Input } from '$lib/components/ui/input';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
@@ -230,9 +231,23 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
|
||||
<ButtonGroup>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="h-8 w-auto">
|
||||
{intervalLabel}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-56" align="end">
|
||||
<div class="grid gap-3">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={polling} onCheckedChange={(v: boolean) => (polling = v)} />
|
||||
{m.dashboard_auto_refresh()}
|
||||
</label>
|
||||
{#if polling}
|
||||
<Select.Root type="single" bind:value={syncInterval}>
|
||||
<Select.Trigger class="h-8 w-auto grow lg:w-35">{intervalLabel}</Select.Trigger>
|
||||
<Select.Trigger class="h-8 w-full">{intervalLabel}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each intervals as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{opt.label}</Select.Item>
|
||||
@@ -240,24 +255,13 @@
|
||||
</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" />
|
||||
<Input type="number" min="1" bind:value={customSec} class="h-8 w-full" />
|
||||
{/if}
|
||||
{/if}
|
||||
</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>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{#if !polling}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 px-2 sm:px-3"
|
||||
@@ -267,6 +271,7 @@
|
||||
<RefreshCw class="size-4!" />
|
||||
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
@@ -306,8 +311,6 @@
|
||||
try {
|
||||
await updateAgent(machineId);
|
||||
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) {
|
||||
@@ -324,9 +327,7 @@
|
||||
}
|
||||
if (!done) toast.error(m.agent_update_failed());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
extractErrorMessage(e) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
@@ -338,14 +339,20 @@
|
||||
</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) -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-0">
|
||||
<KpiCard
|
||||
label={m.dashboard_cpu()}
|
||||
icon={Cpu}
|
||||
value={sys.cpu.model}
|
||||
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
|
||||
/>
|
||||
value={ghz(sys.cpu.current_mhz)}
|
||||
valueClass="tabular-nums {usageText(pct(sys.cpu.current_mhz, sys.cpu.max_mhz))}"
|
||||
detail={sys.cpu.model}
|
||||
>
|
||||
{#snippet extra()}
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
|
||||
</div>
|
||||
{/snippet}
|
||||
</KpiCard>
|
||||
|
||||
<KpiCard
|
||||
label={m.dashboard_memory()}
|
||||
@@ -396,11 +403,8 @@
|
||||
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
|
||||
<div class="col-span-2 w-full h-full">
|
||||
<StoragePanel items={sys.disks ?? []} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2 h-full items-start p-4">
|
||||
<div class="flex flex-col grow gap-4">
|
||||
<CpuHeatmap
|
||||
cpuUsage={sys.load.cpu_usage}
|
||||
cpuModel={sys.cpu.model}
|
||||
@@ -409,16 +413,17 @@
|
||||
maxMhz={sys.cpu.max_mhz}
|
||||
currentMhz={sys.cpu.current_mhz}
|
||||
/>
|
||||
{#if sys.gpus?.length}
|
||||
<GpuCard gpus={sys.gpus} />
|
||||
{/if}
|
||||
<StoragePanel items={sys.disks ?? []} />
|
||||
<ActivityPanel items={log} />
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-4 xl:grid-cols-3 p-4 py-2">
|
||||
<div class="flex flex-col gap-4 lg:sticky lg:top-34">
|
||||
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
|
||||
<NetworkPanel items={sys.network_interfaces ?? []} />
|
||||
<TemperaturePanel items={sys.temperatures ?? []} />
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-2 pb-4">
|
||||
<ActivityPanel items={log} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -442,9 +447,7 @@
|
||||
toast.success(m.machine_edit());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
@@ -513,9 +516,7 @@
|
||||
goto(resolve('/dashboard'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
@@ -538,21 +539,11 @@
|
||||
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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_networking()} description={m.seo_desc_networking()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_networking()}</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })} class="block">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_networking_dns()} description={m.seo_desc_networking_dns()} />
|
||||
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_networking_dns()}</h1>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type Host = { hostnames: null | string[]; ip: string };
|
||||
@@ -25,6 +26,8 @@
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const hosts = $derived(listHosts(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
let editOpen = $state(false);
|
||||
@@ -136,16 +139,16 @@
|
||||
bind:search
|
||||
onrefresh={() => hosts.refresh()}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.pagination_next(),
|
||||
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={openAdd}>
|
||||
<Button onclick={openAdd} disabled={!canWrite}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.networking_host_add()}
|
||||
</Button>
|
||||
@@ -172,9 +175,12 @@
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => openEdit(h)} disabled={!canWrite}
|
||||
>{m.edit()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!canWrite}
|
||||
onclick={() => {
|
||||
deleting = h;
|
||||
deleteOpen = true;
|
||||
|
||||
@@ -23,12 +23,15 @@
|
||||
listInterfaces,
|
||||
rollbackChange
|
||||
} from '$lib/remotes/networking.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const interfaces = $derived(listInterfaces(machineId));
|
||||
const pending = $derived(getPending(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
let busy = $state<null | string>(null);
|
||||
@@ -86,7 +89,6 @@
|
||||
title={m.seo_title_networking_interfaces()}
|
||||
description={m.seo_desc_networking_interfaces()}
|
||||
/>
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
{#if pending.current}
|
||||
<div
|
||||
class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
@@ -101,7 +103,7 @@
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!!busy}
|
||||
disabled={!!busy || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'rollback',
|
||||
@@ -114,7 +116,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!!busy}
|
||||
disabled={!!busy || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'confirm',
|
||||
@@ -128,7 +130,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet sortHead(key: string, label: string)}
|
||||
<button
|
||||
@@ -165,11 +166,11 @@
|
||||
pending.refresh();
|
||||
}}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.pagination_next(),
|
||||
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
@@ -196,13 +197,11 @@
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if i.state === 'up'}
|
||||
<Badge
|
||||
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
|
||||
<Badge class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
|
||||
>{i.state}</Badge
|
||||
>
|
||||
{:else if i.state === 'down'}
|
||||
<Badge
|
||||
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
|
||||
<Badge class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
|
||||
>{i.state}</Badge
|
||||
>
|
||||
{:else}
|
||||
@@ -243,19 +242,20 @@
|
||||
{m.networking_details()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={!canWrite}
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve(
|
||||
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
|
||||
{ machineId, name: i.name }
|
||||
)
|
||||
resolve('/dashboard/[machineId]/networking/interfaces/[name]/configure', {
|
||||
machineId,
|
||||
name: i.name
|
||||
})
|
||||
)}
|
||||
>
|
||||
{m.networking_configure()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
disabled={!!busy || i.state === 'up'}
|
||||
disabled={!!busy || i.state === 'up' || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
`up-${i.name}`,
|
||||
@@ -267,7 +267,7 @@
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!!busy || i.state === 'down'}
|
||||
disabled={!!busy || i.state === 'down' || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
`down-${i.name}`,
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getInterfaceConfig } from '$lib/remotes/networking.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { hasPermission } from '$lib/utils';
|
||||
|
||||
const name = $derived(pageState.params.name!);
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const config = $derived(getInterfaceConfig({ machineId, name }).current);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
|
||||
|
||||
const v4Badge = $derived(config?.method === 'dhcp' ? 'DHCP' : 'Static');
|
||||
const v6Badge = $derived(
|
||||
@@ -27,7 +31,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={name} description={m.seo_desc_networking_interfaces()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -44,6 +48,7 @@
|
||||
</div>
|
||||
{#if config}
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]/configure', {
|
||||
machineId,
|
||||
name
|
||||
@@ -63,19 +68,15 @@
|
||||
<Card.Title>{m.networking_ipv4_section()}</Card.Title>
|
||||
<Badge variant="outline">{v4Badge}</Badge>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{m.machine_address()}</span>
|
||||
<span class="font-mono text-sm">{config.address || '—'}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{m.prefix()}</span>
|
||||
<span class="font-mono text-sm">{config.prefix ?? '—'}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{m.networking_col_gateway()}</span>
|
||||
<span class="font-mono text-sm">{config.gateway || '—'}</span>
|
||||
</div>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">{m.machine_address()}</dt>
|
||||
<dd class="font-mono">{config.address || '—'}</dd>
|
||||
<dt class="text-muted-foreground">{m.prefix()}</dt>
|
||||
<dd class="font-mono">{config.prefix ?? '—'}</dd>
|
||||
<dt class="text-muted-foreground">{m.networking_col_gateway()}</dt>
|
||||
<dd class="font-mono">{config.gateway || '—'}</dd>
|
||||
</dl>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -86,19 +87,15 @@
|
||||
<Badge variant="outline">{v6Badge}</Badge>
|
||||
</Card.Header>
|
||||
{#if config.ipv6}
|
||||
<Card.Content class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{m.machine_address()}</span>
|
||||
<span class="font-mono text-sm">{config.ipv6.address || '—'}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{m.prefix()}</span>
|
||||
<span class="font-mono text-sm">{config.ipv6.prefix ?? '—'}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{m.networking_col_gateway()}</span>
|
||||
<span class="font-mono text-sm">{config.ipv6.gateway || '—'}</span>
|
||||
</div>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">{m.machine_address()}</dt>
|
||||
<dd class="font-mono">{config.ipv6.address || '—'}</dd>
|
||||
<dt class="text-muted-foreground">{m.prefix()}</dt>
|
||||
<dd class="font-mono">{config.ipv6.prefix ?? '—'}</dd>
|
||||
<dt class="text-muted-foreground">{m.networking_col_gateway()}</dt>
|
||||
<dd class="font-mono">{config.ipv6.gateway || '—'}</dd>
|
||||
</dl>
|
||||
</Card.Content>
|
||||
{:else}
|
||||
<Card.Content>
|
||||
|
||||
+13
-6
@@ -14,7 +14,8 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -22,6 +23,8 @@
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
|
||||
const config = $derived(getInterfaceConfig({ machineId, name }).current);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
|
||||
|
||||
let v4Method = $state<'dhcp' | 'static'>('dhcp');
|
||||
let v4 = $state({ address: '', gateway: '', prefix: 24 });
|
||||
@@ -109,7 +112,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -254,7 +257,7 @@
|
||||
<Card.Title>{m.networking_dns_section()}</Card.Title>
|
||||
<Card.Description>{m.networking_dns_section_hint()}</Card.Description>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addDns}>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addDns} disabled={!canWrite}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.networking_dns_add()}
|
||||
</Button>
|
||||
@@ -270,6 +273,7 @@
|
||||
size="icon"
|
||||
class="size-9 shrink-0"
|
||||
onclick={() => removeDns(i)}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<TrashIcon class="size-4" />
|
||||
</Button>
|
||||
@@ -285,7 +289,7 @@
|
||||
<Card.Title>{m.networking_routes_section()}</Card.Title>
|
||||
<Card.Description>{m.networking_routes_section_hint()}</Card.Description>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addRoute}>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addRoute} disabled={!canWrite}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.networking_route_add()}
|
||||
</Button>
|
||||
@@ -308,6 +312,7 @@
|
||||
size="icon"
|
||||
class="size-9 shrink-0"
|
||||
onclick={() => removeRoute(i)}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<TrashIcon class="size-4" />
|
||||
</Button>
|
||||
@@ -331,7 +336,9 @@
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">{m.networking_rollback_seconds_hint()}</p>
|
||||
</div>
|
||||
<Button type="submit" disabled={!valid || submitting}>{m.networking_apply()}</Button>
|
||||
<Button type="submit" disabled={!valid || submitting || !canWrite}
|
||||
>{m.networking_apply()}</Button
|
||||
>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</form>
|
||||
@@ -348,7 +355,7 @@
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={submit} disabled={submitting}>
|
||||
<AlertDialog.Action onclick={submit} disabled={submitting || !canWrite}>
|
||||
{m.networking_apply()}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
bind:search
|
||||
onrefresh={() => routes.refresh()}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.pagination_next(),
|
||||
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_packages()} description={m.seo_desc_packages()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_packages()}</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a href={resolve('/dashboard/[machineId]/packages/installed', { machineId })} class="block">
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
listPackageUpdates,
|
||||
streamPackageAction
|
||||
} from '$lib/remotes/packages.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { hasPermission } from '$lib/utils';
|
||||
|
||||
type Package = { name: string; version: string };
|
||||
|
||||
@@ -34,6 +36,8 @@
|
||||
const updatesResource = $derived(listPackageUpdates(machineId));
|
||||
const manager = $derived(dataResource.current?.manager ?? '');
|
||||
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('packages', 'write', whoami.current?.permissions));
|
||||
// name → new version, for the per-row "update available" badge.
|
||||
const updatesMap = $derived(
|
||||
new Map(
|
||||
@@ -117,13 +121,19 @@
|
||||
];
|
||||
consoleStatus = 'success';
|
||||
} else {
|
||||
consoleLogs = [...consoleLogs, `\n${chunk.data.error ? m.packages_stream_error({ message: chunk.data.error }) : m.packages_stream_failed()}`];
|
||||
consoleLogs = [
|
||||
...consoleLogs,
|
||||
`\n${chunk.data.error ? m.packages_stream_error({ message: chunk.data.error }) : m.packages_stream_failed()}`
|
||||
];
|
||||
consoleStatus = 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
consoleLogs = [...consoleLogs, `\n${m.packages_stream_connection_error({ message: (err as Error).message || `${err}` })}`];
|
||||
consoleLogs = [
|
||||
...consoleLogs,
|
||||
`\n${m.packages_stream_connection_error({ message: (err as Error).message || `${err}` })}`
|
||||
];
|
||||
consoleStatus = 'failed';
|
||||
} finally {
|
||||
await dataResource.refresh();
|
||||
@@ -173,7 +183,7 @@
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<Button onclick={() => (createOpen = true)} disabled={!canWrite}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.packages_install()}
|
||||
</Button>
|
||||
@@ -225,7 +235,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if newVersion}
|
||||
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
|
||||
<DropdownMenu.Item onclick={() => runUpdate(p.name)} disabled={!canWrite}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_update_single()}
|
||||
</DropdownMenu.Item>
|
||||
@@ -233,6 +243,7 @@
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!canWrite}
|
||||
onclick={() => {
|
||||
deleting = p;
|
||||
deleteOpen = true;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { listPackageUpdates, streamPackageAction } from '$lib/remotes/packages.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { hasPermission } from '$lib/utils';
|
||||
|
||||
type Package = { name: string; version: string };
|
||||
|
||||
@@ -22,6 +24,8 @@
|
||||
const dataResource = $derived(listPackageUpdates(machineId));
|
||||
const manager = $derived(dataResource.current?.manager ?? '');
|
||||
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('packages', 'write', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -125,7 +129,7 @@
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
|
||||
<Button onclick={() => doUpgrade()} disabled={!packages.length || !canWrite}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_upgrade_all()}
|
||||
</Button>
|
||||
@@ -161,7 +165,7 @@
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
|
||||
<DropdownMenu.Item onclick={() => doUpgrade(p.name)} disabled={!canWrite}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_update_single()}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -86,17 +86,37 @@
|
||||
}
|
||||
list = list.filter((s) => {
|
||||
const activeCat =
|
||||
s.active === 'active' ? 'active' : s.active === 'inactive' ? 'inactive' : s.active === 'failed' ? 'failed' : 'other';
|
||||
s.active === 'active'
|
||||
? 'active'
|
||||
: s.active === 'inactive'
|
||||
? 'inactive'
|
||||
: s.active === 'failed'
|
||||
? 'failed'
|
||||
: 'other';
|
||||
return filterActive[activeCat];
|
||||
});
|
||||
list = list.filter((s) => {
|
||||
const loadCat =
|
||||
s.load === 'loaded' ? 'loaded' : s.load === 'masked' ? 'masked' : s.load === 'not-found' ? 'notFound' : s.load === 'error' ? 'error' : 'loaded';
|
||||
s.load === 'loaded'
|
||||
? 'loaded'
|
||||
: s.load === 'masked'
|
||||
? 'masked'
|
||||
: s.load === 'not-found'
|
||||
? 'notFound'
|
||||
: s.load === 'error'
|
||||
? 'error'
|
||||
: 'loaded';
|
||||
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
|
||||
});
|
||||
list = list.filter((s) => {
|
||||
const subCat =
|
||||
s.sub === 'running' ? 'running' : s.sub === 'exited' ? 'exited' : s.sub === 'dead' ? 'dead' : 'other';
|
||||
s.sub === 'running'
|
||||
? 'running'
|
||||
: s.sub === 'exited'
|
||||
? 'exited'
|
||||
: s.sub === 'dead'
|
||||
? 'dead'
|
||||
: 'other';
|
||||
return filterSub[subCat as keyof typeof filterSub] ?? true;
|
||||
});
|
||||
list.sort((a, b) => {
|
||||
@@ -120,14 +140,14 @@
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => servicesResource.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
{activeFilterCount}
|
||||
bind:page
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.services_filter_title(),
|
||||
next: () => m.users_next(),
|
||||
pageOf: (p) => m.users_page_of(p),
|
||||
previous: () => m.users_prev(),
|
||||
next: () => m.users_next(),
|
||||
filterTitle: m.services_filter_title(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
@@ -232,7 +252,7 @@
|
||||
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
|
||||
<Table.Head>{m.services_col_description()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(s: ServiceUnit, i: number)}
|
||||
{#snippet row(s: ServiceUnit)}
|
||||
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/services/[name]', {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import ChevronsLeftIcon from '@lucide/svelte/icons/chevrons-left';
|
||||
import ChevronsRightIcon from '@lucide/svelte/icons/chevrons-right';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import Loader2Icon from '@lucide/svelte/icons/loader-2';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import PowerIcon from '@lucide/svelte/icons/power';
|
||||
@@ -22,6 +23,7 @@
|
||||
import * as Empty from '$lib/components/ui/empty';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
@@ -34,13 +36,16 @@
|
||||
stopService,
|
||||
streamServiceLogs
|
||||
} from '$lib/remotes/services.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const name = $derived(pageState.params.name!);
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const statusResource = $derived(getServiceStatus({ machineId, unit: name }));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('services', 'write', whoami.current?.permissions));
|
||||
|
||||
let busy = $state<null | string>(null);
|
||||
|
||||
@@ -185,10 +190,15 @@
|
||||
title={`${name} · ${m.seo_title_services()}`}
|
||||
description={m.seo_desc_services_detail()}
|
||||
/>
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err, reset)}
|
||||
{@const e = err as { body?: { message?: string }; message?: string; status?: number; detail?: string }}
|
||||
{@const e = err as {
|
||||
body?: { message?: string };
|
||||
detail?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
}}
|
||||
{@const status = e.status ?? 0}
|
||||
{@const notFound = status === 404}
|
||||
<Empty.Root class="border">
|
||||
@@ -282,7 +292,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
disabled={!!busy || s.active_state === 'active'}
|
||||
disabled={!!busy || s.active_state === 'active' || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'start',
|
||||
@@ -300,7 +310,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
disabled={!!busy || s.active_state !== 'active'}
|
||||
disabled={!!busy || s.active_state !== 'active' || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'stop',
|
||||
@@ -318,7 +328,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="col-span-2 justify-start"
|
||||
disabled={!!busy}
|
||||
disabled={!!busy || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'restart',
|
||||
@@ -336,7 +346,10 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
disabled={!!busy || s.unit_file_state === 'enabled' || s.unit_file_state === 'static'}
|
||||
disabled={!!busy ||
|
||||
s.unit_file_state === 'enabled' ||
|
||||
s.unit_file_state === 'static' ||
|
||||
!canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'enable',
|
||||
@@ -354,7 +367,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
disabled={!!busy || s.unit_file_state !== 'enabled'}
|
||||
disabled={!!busy || s.unit_file_state !== 'enabled' || !canWrite}
|
||||
onclick={() =>
|
||||
run(
|
||||
'disable',
|
||||
@@ -392,6 +405,87 @@
|
||||
{/if}
|
||||
</Card.Title>
|
||||
<div class="flex items-center gap-3">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.services_logs_filters()}
|
||||
{...props}
|
||||
>
|
||||
<FilterIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-4" align="end">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-lines-{id}" class="text-xs">{m.services_logs_lines()}</Label>
|
||||
<Input
|
||||
id="logs-lines-{id}"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
class="h-9 w-full"
|
||||
value={lines.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1 && n <= 10000) lines.current = n;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-priority-{id}" class="text-xs"
|
||||
>{m.services_logs_priority()}</Label
|
||||
>
|
||||
<select
|
||||
id="logs-priority-{id}"
|
||||
class="border-input bg-background h-9 w-full rounded-md border px-2 text-sm"
|
||||
value={priority.current}
|
||||
onchange={(e) =>
|
||||
(priority.current = Number((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
<option value={0}>{m.syslog_emerg()}</option>
|
||||
<option value={1}>{m.syslog_alert()}</option>
|
||||
<option value={2}>{m.syslog_crit()}</option>
|
||||
<option value={3}>{m.syslog_err()}</option>
|
||||
<option value={4}>{m.syslog_warning()}</option>
|
||||
<option value={5}>{m.syslog_notice()}</option>
|
||||
<option value={6}>{m.syslog_info()}</option>
|
||||
<option value={7}>{m.syslog_debug()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-since-{id}" class="text-xs">{m.services_logs_since()}</Label>
|
||||
<select
|
||||
id="logs-since-{id}"
|
||||
class="border-input bg-background h-9 w-full rounded-md border px-2 text-sm"
|
||||
value={since.current}
|
||||
onchange={(e) => (since.current = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">{m.services_logs_since_all()}</option>
|
||||
<option value="15 minutes ago">{m.services_logs_since_15m()}</option>
|
||||
<option value="1 hour ago">{m.services_logs_since_1h()}</option>
|
||||
<option value="6 hours ago">{m.services_logs_since_6h()}</option>
|
||||
<option value="today">{m.services_logs_since_today()}</option>
|
||||
<option value="yesterday">{m.services_logs_since_yesterday()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-search-{id}" class="text-xs"
|
||||
>{m.services_logs_search_placeholder()}</Label
|
||||
>
|
||||
<Input
|
||||
id="logs-search-{id}"
|
||||
placeholder={m.services_logs_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onLogSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Label class="flex items-center gap-2 text-sm font-normal">
|
||||
<Switch bind:checked={autoscroll.current} />
|
||||
{m.services_logs_autoscroll()}
|
||||
@@ -417,71 +511,8 @@
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-lines-{id}" class="text-xs">{m.services_logs_lines()}</Label>
|
||||
<Input
|
||||
id="logs-lines-{id}"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
class="h-9 w-24"
|
||||
value={lines.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1 && n <= 10000) lines.current = n;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-priority-{id}" class="text-xs">{m.services_logs_priority()}</Label>
|
||||
<select
|
||||
id="logs-priority-{id}"
|
||||
class="border-input bg-background h-9 w-36 rounded-md border px-2 text-sm"
|
||||
value={priority.current}
|
||||
onchange={(e) => (priority.current = Number((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
<option value={0}>{m.syslog_emerg()}</option>
|
||||
<option value={1}>{m.syslog_alert()}</option>
|
||||
<option value={2}>{m.syslog_crit()}</option>
|
||||
<option value={3}>{m.syslog_err()}</option>
|
||||
<option value={4}>{m.syslog_warning()}</option>
|
||||
<option value={5}>{m.syslog_notice()}</option>
|
||||
<option value={6}>{m.syslog_info()}</option>
|
||||
<option value={7}>{m.syslog_debug()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-since-{id}" class="text-xs">{m.services_logs_since()}</Label>
|
||||
<select
|
||||
id="logs-since-{id}"
|
||||
class="border-input bg-background h-9 w-44 rounded-md border px-2 text-sm"
|
||||
value={since.current}
|
||||
onchange={(e) => (since.current = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">{m.services_logs_since_all()}</option>
|
||||
<option value="15 minutes ago">{m.services_logs_since_15m()}</option>
|
||||
<option value="1 hour ago">{m.services_logs_since_1h()}</option>
|
||||
<option value="6 hours ago">{m.services_logs_since_6h()}</option>
|
||||
<option value="today">{m.services_logs_since_today()}</option>
|
||||
<option value="yesterday">{m.services_logs_since_yesterday()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex min-w-48 flex-1 flex-col gap-1">
|
||||
<Label for="logs-search-{id}" class="text-xs"
|
||||
>{m.services_logs_search_placeholder()}</Label
|
||||
>
|
||||
<Input
|
||||
id="logs-search-{id}"
|
||||
placeholder={m.services_logs_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onLogSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground flex items-center justify-between text-xs">
|
||||
<span>{filteredLogs.length} / {entries.length}</span>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{filteredLogs.length} / {entries.length}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_storage()} description={m.seo_desc_storage()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_storage()}</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a href={resolve('/dashboard/[machineId]/storage/mounts', { machineId })} class="block">
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { listFstab, removeMount } from '$lib/remotes/storage.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type FstabEntry = {
|
||||
@@ -24,6 +25,8 @@
|
||||
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const fstab = $derived(listFstab(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('storage', 'write', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
const filtered = $derived.by(() => {
|
||||
@@ -71,11 +74,11 @@
|
||||
bind:search
|
||||
onrefresh={() => fstab.refresh()}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.pagination_next(),
|
||||
pageOf: (p) => m.pagination_page_of(p),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
@@ -88,7 +91,7 @@
|
||||
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(e: FstabEntry, i: number)}
|
||||
{#snippet row(e: FstabEntry)}
|
||||
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
|
||||
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
|
||||
</Table.Cell>
|
||||
@@ -113,6 +116,7 @@
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!canWrite}
|
||||
onclick={() => {
|
||||
deleting = e;
|
||||
deleteOpen = true;
|
||||
|
||||
@@ -15,22 +15,42 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type Mount = { device: string; fstype: string; mountpoint: string; options: string };
|
||||
|
||||
const PSEUDO_FSTYPES = new Set([
|
||||
'autofs', 'binfmt_misc', 'bpf', 'cgroup', 'cgroup2', 'configfs', 'debugfs',
|
||||
'devpts', 'devtmpfs', 'fusectl', 'hugetlbfs', 'mqueue', 'nsfs', 'proc',
|
||||
'pstore', 'ramfs', 'securityfs', 'sysfs', 'tmpfs', 'tracefs'
|
||||
'autofs',
|
||||
'binfmt_misc',
|
||||
'bpf',
|
||||
'cgroup',
|
||||
'cgroup2',
|
||||
'configfs',
|
||||
'debugfs',
|
||||
'devpts',
|
||||
'devtmpfs',
|
||||
'fusectl',
|
||||
'hugetlbfs',
|
||||
'mqueue',
|
||||
'nsfs',
|
||||
'proc',
|
||||
'pstore',
|
||||
'ramfs',
|
||||
'securityfs',
|
||||
'sysfs',
|
||||
'tmpfs',
|
||||
'tracefs'
|
||||
]);
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const mounts = $derived(listMounts(machineId));
|
||||
const fstab = $derived(listFstab(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('storage', 'write', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
const filtersStore = new PersistedState('storage.mounts.filters', { showPseudo: false });
|
||||
@@ -110,18 +130,18 @@
|
||||
pageSizePresets={[10, 20, 50, 100]}
|
||||
bind:search
|
||||
onrefresh={() => mounts.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
{activeFilterCount}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.pagination_next(),
|
||||
pageOf: (p) => m.pagination_page_of(p),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<Button onclick={() => (createOpen = true)} disabled={!canWrite}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.storage_mount_add()}
|
||||
</Button>
|
||||
@@ -147,7 +167,7 @@
|
||||
<Table.Head>{m.storage_col_options()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(mt: Mount, i: number)}
|
||||
{#snippet row(mt: Mount)}
|
||||
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
|
||||
@@ -175,6 +195,7 @@
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!canWrite}
|
||||
onclick={() => {
|
||||
deleting = mt;
|
||||
deleteOpen = true;
|
||||
@@ -201,7 +222,12 @@
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
|
||||
<Input id="sm-device-{id}" bind:value={createForm.device} placeholder="/dev/sdb1" required />
|
||||
<Input
|
||||
id="sm-device-{id}"
|
||||
bind:value={createForm.device}
|
||||
placeholder="/dev/sdb1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
|
||||
|
||||
@@ -26,9 +26,14 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_system()} description={m.seo_desc_system()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system()}</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="col-span-full">
|
||||
<h2 class="text-muted-foreground text-xs font-semibold uppercase tracking-widest">
|
||||
{m.system_section_configuration()}
|
||||
</h2>
|
||||
</div>
|
||||
<a href={resolve('/dashboard/[machineId]/system/nadir', { machineId })} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
@@ -40,6 +45,7 @@
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
|
||||
<a href={resolve('/dashboard/[machineId]/system/date-time', { machineId })} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
@@ -73,17 +79,6 @@
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
<a href={resolve('/dashboard/[machineId]/system/power', { machineId })} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<PowerIcon class="size-5" />
|
||||
<Card.Title>{m.nav_system_power()}</Card.Title>
|
||||
</div>
|
||||
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{#if canShowTerminal}
|
||||
<button
|
||||
onclick={() => (terminalState.open = true)}
|
||||
@@ -100,5 +95,22 @@
|
||||
</Card.Root>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="col-span-full">
|
||||
<h2 class="text-muted-foreground text-xs font-semibold uppercase tracking-widest">
|
||||
{m.system_section_power()}
|
||||
</h2>
|
||||
</div>
|
||||
<a href={resolve('/dashboard/[machineId]/system/power', { machineId })} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<PowerIcon class="size-5" />
|
||||
<Card.Title>{m.nav_system_power()}</Card.Title>
|
||||
</div>
|
||||
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,16 @@
|
||||
setTimezone,
|
||||
systemTime
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
|
||||
const time = $derived(systemTime(machineId));
|
||||
const tzs = $derived(listTimezones(machineId));
|
||||
const formId = $props.id();
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
|
||||
|
||||
let tzOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
@@ -49,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_system_datetime()} description={m.seo_desc_system_datetime()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
|
||||
|
||||
<svelte:boundary>
|
||||
@@ -64,16 +66,34 @@
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_current()}</Card.Title>
|
||||
<Card.Description>{t.time}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_timezone()}</Card.Title>
|
||||
<Card.Description>{t.timezone}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Card.Content class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
|
||||
>{m.system_time_timezone()}</span
|
||||
>
|
||||
<span class="font-mono font-medium">{t.timezone}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
|
||||
>{m.system_time_clock()}</span
|
||||
>
|
||||
<span class="font-mono tabular-nums">{t.time}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider">NTP</span
|
||||
>
|
||||
<Switch
|
||||
checked={t.ntp}
|
||||
disabled={saving || !t.can_ntp || !canWrite}
|
||||
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
|
||||
/>
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Content class="border-t pt-4">
|
||||
<Popover.Root bind:open={tzOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
@@ -82,6 +102,7 @@
|
||||
role="combobox"
|
||||
aria-expanded={tzOpen}
|
||||
class="w-full justify-between sm:w-80"
|
||||
disabled={!canWrite}
|
||||
{...props}
|
||||
>
|
||||
{t.timezone}
|
||||
@@ -116,23 +137,6 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_ntp()}</Card.Title>
|
||||
<Card.Description>{m.system_time_ntp_hint()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex items-center justify-between gap-4">
|
||||
<div class="text-muted-foreground text-sm">
|
||||
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
|
||||
</div>
|
||||
<Switch
|
||||
checked={t.ntp}
|
||||
disabled={saving || !t.can_ntp}
|
||||
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_manual()}</Card.Title>
|
||||
@@ -159,7 +163,7 @@
|
||||
disabled={t.ntp}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={t.ntp || saving}>{m.save()}</Button>
|
||||
<Button type="submit" disabled={t.ntp || saving || !canWrite}>{m.save()}</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
@@ -15,6 +16,8 @@
|
||||
const host = $derived(systemHostname(machineId));
|
||||
const formId = $props.id();
|
||||
let saving = $state(false);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
|
||||
|
||||
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
|
||||
const HOSTNAME_RE =
|
||||
@@ -22,7 +25,7 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_system_hostname()} description={m.seo_desc_system_hostname()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
|
||||
|
||||
<svelte:boundary>
|
||||
@@ -73,7 +76,7 @@
|
||||
maxlength={253}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>{m.save()}</Button>
|
||||
<Button type="submit" disabled={saving || !canWrite}>{m.save()}</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
setLocale,
|
||||
systemLocale
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
|
||||
|
||||
const locale = $derived(systemLocale(machineId));
|
||||
const locales = $derived(listLocales(machineId));
|
||||
@@ -86,7 +89,7 @@
|
||||
title={m.seo_title_system_localization()}
|
||||
description={m.seo_desc_system_localization()}
|
||||
/>
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
|
||||
|
||||
<svelte:boundary>
|
||||
@@ -113,6 +116,7 @@
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class="w-full justify-between sm:w-80"
|
||||
disabled={!canWrite}
|
||||
{...props}
|
||||
>
|
||||
{l.lang}
|
||||
@@ -163,7 +167,7 @@
|
||||
type="button"
|
||||
class="hover:bg-muted ms-0.5 rounded-sm p-0.5"
|
||||
aria-label="Remove"
|
||||
disabled={saving}
|
||||
disabled={saving || !canWrite}
|
||||
onclick={() => (langList = langList.filter((_, j) => j !== i))}
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
@@ -176,7 +180,7 @@
|
||||
<Popover.Root bind:open={langOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" size="sm" disabled={saving} {...props}>
|
||||
<Button variant="outline" size="sm" disabled={saving || !canWrite} {...props}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.system_locale_language_add()}
|
||||
</Button>
|
||||
@@ -210,7 +214,7 @@
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={saving || !languageDirty}
|
||||
disabled={saving || !languageDirty || !canWrite}
|
||||
onclick={() => pick(handleSetLanguage)}
|
||||
>
|
||||
{m.system_locale_language_button()}
|
||||
@@ -236,6 +240,7 @@
|
||||
role="combobox"
|
||||
aria-expanded={kmOpen}
|
||||
class="w-full justify-between sm:w-80"
|
||||
disabled={!canWrite}
|
||||
{...props}
|
||||
>
|
||||
{l.vc_keymap || '—'}
|
||||
@@ -292,7 +297,7 @@
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={saving || !localeValid}
|
||||
disabled={saving || !localeValid || !canWrite}
|
||||
onclick={() => pick(handleGenerate)}
|
||||
>
|
||||
{m.system_locale_generate_button()}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import CompassIcon from '@lucide/svelte/icons/compass';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
@@ -14,7 +13,6 @@
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -76,17 +74,8 @@
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
href={resolve('/dashboard/[machineId]/system', { machineId })}
|
||||
title={m.error_action_back()}
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
</Button>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.seo_title_system_nadir()}</h1>
|
||||
<p class="text-muted-foreground text-sm">{m.nav_system_nadir_desc()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,13 +102,13 @@
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<!-- Whoami Details Card -->
|
||||
<Card.Root class="relative overflow-hidden md:col-span-1 border border-border bg-linear-to-br from-card/85 to-card/50 shadow-lg backdrop-blur-md">
|
||||
<!-- Visual Accent Gradient -->
|
||||
<div class="absolute -right-16 -top-16 size-32 rounded-full bg-emerald-500/10 blur-3xl"></div>
|
||||
<Card.Root class="md:col-span-1 border border-border">
|
||||
<Card.Header class="pb-3">
|
||||
<div class="flex items-center gap-2 text-emerald-500">
|
||||
<FingerprintIcon class="size-5" />
|
||||
<Card.Title class="text-sm font-semibold tracking-wide uppercase">{m.system_nadir_username()}</Card.Title>
|
||||
<Card.Title class="text-sm font-semibold tracking-wide uppercase"
|
||||
>{m.system_nadir_username()}</Card.Title
|
||||
>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
@@ -129,14 +118,20 @@
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-3 space-y-2">
|
||||
<span class="text-muted-foreground text-xs font-medium block uppercase tracking-wider">{m.system_nadir_permissions()}</span>
|
||||
<span class="text-muted-foreground text-xs font-medium block uppercase tracking-wider"
|
||||
>{m.system_nadir_permissions()}</span
|
||||
>
|
||||
{#if hasGlobalWildcard}
|
||||
<div class="flex items-center gap-2 rounded-md bg-emerald-500/10 p-2 text-xs border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-emerald-500/10 p-2 text-xs border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 font-medium"
|
||||
>
|
||||
<ShieldCheckIcon class="size-4 shrink-0" />
|
||||
<span>Administrator (Full Access)</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-md bg-sky-500/10 p-2 text-xs border border-sky-500/20 text-sky-600 dark:text-sky-400 font-medium">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-sky-500/10 p-2 text-xs border border-sky-500/20 text-sky-600 dark:text-sky-400 font-medium"
|
||||
>
|
||||
<ShieldAlertIcon class="size-4 shrink-0" />
|
||||
<span>Restricted Module Access</span>
|
||||
</div>
|
||||
@@ -149,26 +144,41 @@
|
||||
<div class="flex flex-col gap-4 md:col-span-2">
|
||||
<h2 class="text-lg font-medium tracking-tight flex items-center gap-2">
|
||||
<span>{m.system_nadir_modules()}</span>
|
||||
<Badge variant="secondary" class="font-mono text-xs">{modulesData?.modules?.length ?? 0}</Badge>
|
||||
<Badge variant="secondary" class="font-mono text-xs"
|
||||
>{modulesData?.modules?.length ?? 0}</Badge
|
||||
>
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each modulesData?.modules ?? [] as mod (mod.id)}
|
||||
{@const Icon = getModuleIcon(mod.id)}
|
||||
{@const hasModuleWildcard = !hasGlobalWildcard && whoamiData.permissions[mod.id]?.includes('*')}
|
||||
<Card.Root class="group transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md border border-border/80 hover:border-emerald-500/30">
|
||||
{@const hasModuleWildcard =
|
||||
!hasGlobalWildcard && whoamiData.permissions[mod.id]?.includes('*')}
|
||||
<Card.Root
|
||||
class="group transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md border border-border/80 hover:border-emerald-500/30"
|
||||
>
|
||||
<Card.Header class="pb-3 flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-muted/40 p-2 text-muted-foreground group-hover:text-emerald-500 group-hover:bg-emerald-500/10 transition-colors">
|
||||
<div
|
||||
class="rounded-lg bg-muted/40 p-2 text-muted-foreground group-hover:text-emerald-500 group-hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Card.Title class="text-sm font-semibold tracking-tight leading-none capitalize">{mod.name}</Card.Title>
|
||||
<span class="text-muted-foreground text-[10px] font-mono leading-none">{mod.id}</span>
|
||||
<Card.Title
|
||||
class="text-sm font-semibold tracking-tight leading-none capitalize"
|
||||
>{mod.name}</Card.Title
|
||||
>
|
||||
<span class="text-muted-foreground text-[10px] font-mono leading-none"
|
||||
>{mod.id}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if hasGlobalWildcard || hasModuleWildcard}
|
||||
<Badge class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 p-1 rounded-full" title="Full access">
|
||||
<Badge
|
||||
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 p-1 rounded-full"
|
||||
title="Full access"
|
||||
>
|
||||
<ShieldCheckIcon class="size-3.5" />
|
||||
</Badge>
|
||||
{/if}
|
||||
@@ -194,7 +204,9 @@
|
||||
</Badge>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs italic">{m.system_nadir_no_permissions()}</span>
|
||||
<span class="text-muted-foreground text-xs italic"
|
||||
>{m.system_nadir_no_permissions()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { powerOff, reboot } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let pending = $state<'off' | 'reboot' | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canRoot = $derived(hasPermission('system', 'root', whoami.current?.permissions));
|
||||
async function run(action: 'off' | 'reboot') {
|
||||
busy = true;
|
||||
try {
|
||||
@@ -33,25 +35,20 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_system_power()} description={m.seo_desc_system_power()} />
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_power()}</h1>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.nav_system_power()}</Card.Title>
|
||||
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={busy} onclick={() => (pending = 'reboot')}>
|
||||
<p class="text-muted-foreground text-sm">{m.nav_system_power_desc()}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={busy || !canRoot} onclick={() => (pending = 'reboot')}>
|
||||
<RotateCcwIcon class="size-4" />
|
||||
{m.system_power_reboot()}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={busy} onclick={() => (pending = 'off')}>
|
||||
<Button variant="destructive" disabled={busy || !canRoot} onclick={() => (pending = 'off')}>
|
||||
<PowerIcon class="size-4" />
|
||||
{m.system_power_poweroff()}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
listPamUsers,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type PamUser = {
|
||||
@@ -43,6 +44,9 @@
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const users = $derived(listPamUsers(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('users', 'write', whoami.current?.permissions));
|
||||
const canRoot = $derived(hasPermission('users', 'root', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -205,19 +209,19 @@
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => users.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
{activeFilterCount}
|
||||
bind:page
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter_title(),
|
||||
next: () => m.users_next(),
|
||||
pageOf: (p) => m.users_page_of(p),
|
||||
previous: () => m.users_prev(),
|
||||
next: () => m.users_next(),
|
||||
filterTitle: m.users_filter_title(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<Button onclick={() => (createOpen = true)} disabled={!canWrite && !canRoot}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.users_add()}
|
||||
</Button>
|
||||
@@ -273,7 +277,7 @@
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(u: PamUser, i: number)}
|
||||
{#snippet row(u: PamUser)}
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/users/[username]', {
|
||||
@@ -305,6 +309,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
disabled={!canRoot}
|
||||
onclick={() => {
|
||||
pwUser = u;
|
||||
pwValue = '';
|
||||
@@ -314,6 +319,7 @@
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!canRoot}
|
||||
onclick={() => {
|
||||
deleting = u;
|
||||
removeHome = false;
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
setPamUserGroups,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -30,6 +31,8 @@
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const user = $derived(getPamUser({ machineId, username }));
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canRoot = $derived(hasPermission('users', 'root', whoami.current?.permissions));
|
||||
|
||||
const primary = $derived((groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1)));
|
||||
const supplementary = $derived(
|
||||
@@ -127,11 +130,11 @@
|
||||
<p class="text-muted-foreground text-sm">{u.comment || m.users_no_gecos()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" onclick={() => (pwOpen = true)}>
|
||||
<Button variant="outline" onclick={() => (pwOpen = true)} disabled={!canRoot}>
|
||||
<KeyIcon class="size-4" />
|
||||
{m.users_action_set_password()}
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
|
||||
<Button variant="destructive" onclick={() => (deleteOpen = true)} disabled={!canRoot}>
|
||||
<Trash2Icon class="size-4" />
|
||||
{m.users_delete()}
|
||||
</Button>
|
||||
@@ -170,7 +173,9 @@
|
||||
</Card.Description>
|
||||
</div>
|
||||
{#if !editing}
|
||||
<Button variant="outline" onclick={() => (editing = true)}>{m.edit()}</Button>
|
||||
<Button variant="outline" onclick={() => (editing = true)} disabled={!canRoot}
|
||||
>{m.edit()}</Button
|
||||
>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type PamGroup = {
|
||||
@@ -35,6 +36,9 @@
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('groups', 'write', whoami.current?.permissions));
|
||||
const canRoot = $derived(hasPermission('groups', 'root', whoami.current?.permissions));
|
||||
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -148,19 +152,19 @@
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => groups.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
{activeFilterCount}
|
||||
bind:page
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter_title(),
|
||||
next: () => m.users_next(),
|
||||
pageOf: (p) => m.users_page_of(p),
|
||||
previous: () => m.users_prev(),
|
||||
next: () => m.users_next(),
|
||||
filterTitle: m.users_filter_title(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<Button onclick={() => (createOpen = true)} disabled={!canWrite && !canRoot}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.groups_add()}
|
||||
</Button>
|
||||
@@ -204,7 +208,7 @@
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(g: PamGroup, i: number)}
|
||||
{#snippet row(g: PamGroup)}
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
|
||||
@@ -218,7 +222,9 @@
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{#if g.members?.length}
|
||||
<span title={g.members.join(', ')}>
|
||||
{g.members.slice(0, 3).join(', ')}{g.members.length > 3 ? ` +${g.members.length - 3}` : ''}
|
||||
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
|
||||
? ` +${g.members.length - 3}`
|
||||
: ''}
|
||||
</span>
|
||||
{:else}
|
||||
—
|
||||
@@ -243,6 +249,7 @@
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!canRoot}
|
||||
onclick={() => {
|
||||
deleting = g;
|
||||
deleteOpen = true;
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
listPamUsers,
|
||||
setPamUserGroups
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -28,6 +29,9 @@
|
||||
const groupName = $derived(pageState.params.group!);
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
const users = $derived(listPamUsers(machineId));
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canDeleteGroup = $derived(hasPermission('groups', 'root', whoami.current?.permissions));
|
||||
const canManageMembers = $derived(hasPermission('users', 'root', whoami.current?.permissions));
|
||||
|
||||
const group = $derived((groups.current ?? []).find((g) => g.name === groupName));
|
||||
const members = $derived(group?.members ?? []);
|
||||
@@ -125,7 +129,7 @@
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
|
||||
</div>
|
||||
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
|
||||
<Button variant="destructive" onclick={() => (deleteOpen = true)} disabled={!canDeleteGroup}>
|
||||
<Trash2Icon class="size-4" />
|
||||
{m.users_delete()}
|
||||
</Button>
|
||||
@@ -140,7 +144,7 @@
|
||||
{@html m.groups_members_description({ gid: group.gid })}
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (addOpen = true)}>
|
||||
<Button variant="outline" onclick={() => (addOpen = true)} disabled={!canManageMembers}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.groups_add_member()}
|
||||
</Button>
|
||||
@@ -164,7 +168,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
|
||||
disabled={busy === name}
|
||||
disabled={busy === name || !canManageMembers}
|
||||
onclick={() => removeMember(name)}
|
||||
aria-label={m.groups_remove_member_aria({ name })}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import Orbit from '@lucide/svelte/icons/orbit';
|
||||
import ScaleIcon from '@lucide/svelte/icons/scale';
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
href: '/docs' as const,
|
||||
icon: BookOpenIcon,
|
||||
label: () => m.docs_nav_intro()
|
||||
},
|
||||
{
|
||||
href: '/docs/installation' as const,
|
||||
icon: DownloadIcon,
|
||||
label: () => m.docs_nav_installation()
|
||||
},
|
||||
{
|
||||
href: '/docs/architecture' as const,
|
||||
icon: CpuIcon,
|
||||
label: () => m.docs_nav_architecture()
|
||||
},
|
||||
{
|
||||
href: '/docs/security' as const,
|
||||
icon: ShieldAlertIcon,
|
||||
label: () => m.docs_nav_security()
|
||||
},
|
||||
{
|
||||
href: '/docs/limitations' as const,
|
||||
icon: ScaleIcon,
|
||||
label: () => m.docs_nav_limitations()
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<header
|
||||
class="bg-background/80 border-b border-border/60 fixed top-0 z-50 flex h-16 w-full items-center gap-4 px-6 backdrop-blur-md"
|
||||
>
|
||||
<a href={resolve('/')} class="flex items-center gap-2.5 font-semibold text-foreground">
|
||||
<Orbit class="text-primary size-5 animate-pulse" />
|
||||
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
|
||||
</a>
|
||||
<span class="text-muted-foreground/30 text-lg font-light">/</span>
|
||||
<span class="font-mono text-xs uppercase tracking-widest text-muted-foreground font-semibold"
|
||||
>{m.docs_title()}</span
|
||||
>
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<a
|
||||
href={resolve('/dashboard')}
|
||||
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<span>{m.docs_go_to_dashboard()}</span>
|
||||
<ArrowRightIcon class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-screen pt-16 text-foreground antialiased selection:bg-primary/20">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside
|
||||
class="fixed bottom-0 top-16 z-30 hidden w-64 shrink-0 border-r border-border/60 bg-background md:block"
|
||||
>
|
||||
<div class="h-full overflow-y-auto px-4 py-8">
|
||||
<nav class="flex flex-col gap-1.5">
|
||||
{#each menuItems as item (item.href)}
|
||||
{@const active = page.url.pathname === item.href}
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all {active
|
||||
? 'bg-primary/10 font-semibold text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
|
||||
>
|
||||
<item.icon class="size-4 shrink-0" stroke-width={active ? '2.5' : '2'} />
|
||||
<span>{item.label()}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 md:pl-64">
|
||||
<div class="mx-auto max-w-4xl px-6 py-12 md:py-16">
|
||||
<!-- Mobile Navigation Header -->
|
||||
<nav class="mb-8 flex flex-wrap gap-2 border-b border-border/60 pb-6 md:hidden">
|
||||
{#each menuItems as item (item.href)}
|
||||
{@const active = page.url.pathname === item.href}
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition-all {active
|
||||
? 'bg-primary/10 font-semibold text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
|
||||
>
|
||||
<item.icon class="size-3.5" />
|
||||
<span>{item.label()}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Page Content Injection -->
|
||||
<article class="prose prose-muted dark:prose-invert max-w-none">
|
||||
{@render children()}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-at-html-tags */
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
||||
import Orbit from '@lucide/svelte/icons/orbit';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { resolve } from '$app/paths';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
const coreModules = $derived([
|
||||
{ desc: m.docs_intro_mod_system_desc(), icon: CpuIcon, title: m.nav_system() },
|
||||
{ desc: m.docs_intro_mod_services_desc(), icon: ServerIcon, title: m.nav_services() },
|
||||
{ desc: m.docs_intro_mod_users_desc(), icon: UsersIcon, title: m.groups_nav_title() },
|
||||
{ desc: m.docs_intro_mod_storage_desc(), icon: HardDrive, title: m.nav_storage() },
|
||||
{ desc: m.docs_intro_mod_networking_desc(), icon: GlobeIcon, title: m.nav_networking() },
|
||||
{
|
||||
desc: m.docs_intro_mod_audit_desc(),
|
||||
icon: ShieldAlertIcon,
|
||||
title: m.docs_intro_mod_audit_title()
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- Headline -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
|
||||
{m.docs_intro_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_intro_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Core Architecture Description -->
|
||||
<div class="border-y border-border/60 py-8 space-y-6">
|
||||
<div class="flex items-center gap-3 text-primary">
|
||||
<Orbit class="size-6 animate-pulse" />
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_intro_how_title()}</h2>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{@html m.docs_intro_how_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features / Modules Grid -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.docs_intro_modules_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-xl">
|
||||
{m.docs_intro_modules_desc()}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
|
||||
{#each coreModules as mod (mod.title)}
|
||||
<div class="border border-border/60 rounded-xl p-5 bg-muted/5 flex gap-4">
|
||||
<div class="text-primary mt-0.5 shrink-0">
|
||||
<mod.icon class="size-5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-bold text-foreground text-base">{mod.title}</h3>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">{mod.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps Callout -->
|
||||
<div
|
||||
class="bg-primary/5 border border-primary/10 rounded-xl p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-bold text-foreground text-base">{m.docs_intro_ready_title()}</h3>
|
||||
<p class="text-muted-foreground text-sm">{m.docs_intro_ready_desc()}</p>
|
||||
</div>
|
||||
<a href={resolve('/docs/installation')} class={buttonVariants({ size: 'sm' })}>
|
||||
<span>{m.docs_intro_ready_cta()}</span>
|
||||
<ArrowRightIcon class="size-4 ms-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-at-html-tags */
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- Headline -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
|
||||
{m.docs_arch_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_arch_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Technical Boundaries -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-2.5 text-primary">
|
||||
<CpuIcon class="size-5" />
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_bounds_title()}</h2>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_arch_bounds_desc()}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-6 pt-4">
|
||||
<!-- Web UI Card -->
|
||||
<Card class="border-border bg-muted/5">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-base font-bold">{m.docs_arch_card_webui_title()}</CardTitle>
|
||||
<CardDescription class="text-sm">{m.docs_arch_card_webui_desc()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground space-y-2">
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li>{m.docs_arch_card_webui_item1()}</li>
|
||||
<li>{m.docs_arch_card_webui_item2()}</li>
|
||||
<li>{m.docs_arch_card_webui_item3()}</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Connection arrow / details -->
|
||||
<div
|
||||
class="flex flex-row md:flex-col items-center justify-center gap-2 text-muted-foreground min-w-[120px]"
|
||||
>
|
||||
<div class="h-px w-full md:w-px md:h-12 bg-border/80"></div>
|
||||
<div
|
||||
class="flex flex-col items-center text-center px-3 py-1.5 bg-muted/40 border border-border/50 rounded-full font-mono text-[10px] tracking-wide shrink-0"
|
||||
>
|
||||
<span class="text-primary font-semibold">{m.docs_arch_card_flow_title()}</span>
|
||||
<span>{m.docs_arch_card_flow_desc()}</span>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<ArrowRightIcon class="size-4 text-muted-foreground/60 animate-pulse" />
|
||||
</div>
|
||||
<div class="h-px w-full md:w-px md:h-12 bg-border/80"></div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Backend Card -->
|
||||
<Card class="border-border bg-muted/5">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-base font-bold">{m.docs_arch_card_agent_title()}</CardTitle>
|
||||
<CardDescription class="text-sm">{m.docs_arch_card_agent_desc()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground space-y-2">
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li>{m.docs_arch_card_agent_item1()}</li>
|
||||
<li>{m.docs_arch_card_agent_item2()}</li>
|
||||
<li>{m.docs_arch_card_agent_item3()}</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The Self-Contained Go Daemon -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_daemon_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_arch_daemon_desc()}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_arch_daemon_desc2()}
|
||||
</p>
|
||||
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-2 leading-relaxed">
|
||||
<li>{@html m.docs_arch_daemon_item1()}</li>
|
||||
<li>{@html m.docs_arch_daemon_item2()}</li>
|
||||
<li>{@html m.docs_arch_daemon_item3()}</li>
|
||||
<li>{@html m.docs_arch_daemon_item4()}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- PAM Service Stack -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_pam_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{@html m.docs_arch_pam_desc()}
|
||||
</p>
|
||||
<pre
|
||||
class="overflow-x-auto bg-muted border border-border/60 rounded-xl p-4 text-sm font-mono text-muted-foreground">
|
||||
#%PAM-1.0
|
||||
auth required pam_unix.so
|
||||
account required pam_unix.so
|
||||
</pre>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{@html m.docs_arch_pam_desc2()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Minisign Signature Verification -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_minisign_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_arch_minisign_desc()}
|
||||
</p>
|
||||
<ol class="list-decimal pl-5 text-muted-foreground text-sm space-y-2 leading-relaxed">
|
||||
<li>{@html m.docs_arch_minisign_item1()}</li>
|
||||
<li>{@html m.docs_arch_minisign_item2()}</li>
|
||||
<li>{@html m.docs_arch_minisign_item3()}</li>
|
||||
<li>{@html m.docs_arch_minisign_item4()}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-at-html-tags */
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import TerminalIcon from '@lucide/svelte/icons/terminal';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { CopyButton } from '$lib/components/ui/copy-button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
const nadirHost = $derived(env.PUBLIC_NADIR_URL || 'nadir.urania.dev');
|
||||
const installCommand = $derived(`curl -fsSL https://${nadirHost}/install.sh | sudo sh`);
|
||||
const dockerCommand = 'docker run -d -p 3000:3000 urania/nadir-frontend';
|
||||
</script>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- Headline -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
|
||||
{m.docs_install_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_install_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Web UI -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-2.5 text-primary">
|
||||
<DownloadIcon class="size-5" />
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_install_webui_title()}</h2>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_install_webui_desc()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="font-mono text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">{dockerCommand}</span>
|
||||
<CopyButton text={dockerCommand} size="sm" variant="ghost" />
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||
{@html m.docs_install_webui_run()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Agent Node -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<div class="flex items-center gap-2.5 text-primary">
|
||||
<TerminalIcon class="size-5" />
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_install_agent_title()}</h2>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{@html m.docs_install_agent_desc()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="font-mono text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">{installCommand}</span>
|
||||
<CopyButton text={installCommand} size="sm" variant="ghost" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="flex items-start gap-2.5 border border-red-500/20 bg-red-500/5 rounded-lg p-4 text-sm leading-relaxed text-pretty text-muted-foreground"
|
||||
>
|
||||
<ShieldAlertIcon class="size-4 shrink-0 text-red-500 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<strong
|
||||
class="text-red-700/90 dark:text-red-500 font-semibold uppercase tracking-wider font-mono text-[10px]"
|
||||
>{m.docs_install_disclaimer_title()}</strong
|
||||
>
|
||||
<p>{@html m.docs_install_disclaimer_desc({ nadirHost })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-2.5 border border-amber-500/20 bg-amber-500/5 rounded-lg p-4 text-sm leading-relaxed text-pretty text-muted-foreground"
|
||||
>
|
||||
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<strong
|
||||
class="text-amber-700/90 dark:text-amber-500 font-semibold uppercase tracking-wider font-mono text-[10px]"
|
||||
>{m.docs_install_privilege_title()}</strong
|
||||
>
|
||||
<p>{@html m.docs_install_privilege_desc()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_install_tasks_intro()}
|
||||
</p>
|
||||
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-2 leading-relaxed">
|
||||
<li>{@html m.docs_install_task_1()}</li>
|
||||
<li>{@html m.docs_install_task_2()}</li>
|
||||
<li>{@html m.docs_install_task_3()}</li>
|
||||
<li>{@html m.docs_install_task_4()}</li>
|
||||
<li>{@html m.docs_install_task_5()}</li>
|
||||
<li>{@html m.docs_install_task_6()}</li>
|
||||
<li>{@html m.docs_install_task_7()}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Connection -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_install_link_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{@html m.docs_install_link_desc()}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_install_link_manual()}
|
||||
</p>
|
||||
<div
|
||||
class="font-mono text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="overflow-x-auto whitespace-nowrap">sudo nadir token add dashboard</span>
|
||||
<CopyButton text="sudo nadir token add dashboard" size="sm" variant="ghost" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-at-html-tags */
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- Headline -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
|
||||
{m.docs_limits_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_limits_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- System Limitations -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_limits_sys_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_limits_sys_desc()}
|
||||
</p>
|
||||
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-3 leading-relaxed">
|
||||
<li>{@html m.docs_limits_sys_item1()}</li>
|
||||
<li>{@html m.docs_limits_sys_item2()}</li>
|
||||
<li>{@html m.docs_limits_sys_item3()}</li>
|
||||
<li>{@html m.docs_limits_sys_item4()}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- License Details -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_limits_license_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_limits_license_desc()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="bg-muted border border-border/60 rounded-xl p-5 font-mono text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
<p class="font-bold text-foreground mb-3">{m.docs_limits_license_mit_title()}</p>
|
||||
<p class="mb-3">{m.docs_limits_license_mit_copyright()}</p>
|
||||
<p class="mb-3">{m.docs_limits_license_mit_body1()}</p>
|
||||
<p class="mb-3">{m.docs_limits_license_mit_body2()}</p>
|
||||
<p>{m.docs_limits_license_mit_body3()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
<div class="space-y-4 border-t border-border/60 pt-8 text-sm text-muted-foreground">
|
||||
<h3 class="font-bold text-foreground text-sm">{m.docs_limits_credits_title()}</h3>
|
||||
<p>{m.docs_limits_credits_desc()}</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>{@html m.docs_limits_credits_item1()}</li>
|
||||
<li>{@html m.docs_limits_credits_item2()}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-at-html-tags */
|
||||
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- Headline -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
|
||||
{m.docs_security_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_security_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Critical Warning -->
|
||||
<div
|
||||
class="flex items-start gap-3 border border-amber-500/20 bg-amber-500/5 rounded-xl p-6 text-sm leading-relaxed text-pretty text-muted-foreground"
|
||||
>
|
||||
<ShieldAlertIcon class="size-5 shrink-0 text-amber-500 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<strong
|
||||
class="text-amber-700/90 dark:text-amber-500 font-mono text-xs uppercase tracking-wider"
|
||||
>{m.docs_security_warning_title()}</strong
|
||||
>
|
||||
<p>{@html m.docs_security_warning_desc()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggested Deployments -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_security_deploy_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_security_deploy_desc()}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
|
||||
<div class="border border-border/60 rounded-xl p-5 bg-muted/5 space-y-2">
|
||||
<h3 class="font-bold text-foreground text-base">{m.docs_security_deploy_coloc_title()}</h3>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||
{@html m.docs_security_deploy_coloc_desc()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border border-border/60 rounded-xl p-5 bg-muted/5 space-y-2">
|
||||
<h3 class="font-bold text-foreground text-base">{m.docs_security_deploy_vpn_title()}</h3>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||
{@html m.docs_security_deploy_vpn_desc()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VPN ACL & Firewall -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_security_acl_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_security_acl_desc()}
|
||||
</p>
|
||||
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-3 leading-relaxed">
|
||||
<li>
|
||||
{@html m.docs_security_acl_tailscale()}
|
||||
<pre
|
||||
class="mt-2 bg-muted p-3 rounded-lg overflow-x-auto">"src": ["tag:nadir-ui"], "dst": ["tag:nadir-agent:9999"]</pre>
|
||||
</li>
|
||||
<li>{@html m.docs_security_acl_netbird()}</li>
|
||||
<li>
|
||||
{@html m.docs_security_acl_iptables()}
|
||||
<pre
|
||||
class="mt-2 bg-muted p-3 rounded-lg overflow-x-auto">iptables -A INPUT -i wg0 ! -s <proxy-wg-ip> -p tcp --dport 9999 -j DROP</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- TLS Configuration -->
|
||||
<div class="space-y-6 border-t border-border/60 pt-8">
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.docs_security_tls_title()}</h2>
|
||||
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
|
||||
{m.docs_security_tls_desc()}
|
||||
</p>
|
||||
<ol class="list-decimal pl-5 text-muted-foreground text-sm space-y-3 leading-relaxed">
|
||||
<li>{@html m.docs_security_tls_proxy_desc()}</li>
|
||||
<li>{@html m.docs_security_tls_direct_desc()}</li>
|
||||
<li>{@html m.docs_security_tls_self_desc()}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,69 +1,44 @@
|
||||
import { env } from '$lib/const/schema';
|
||||
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const repo = process.env.NADIR_RELEASE_REPO ?? '__NADIR_RELEASE_REPO__';
|
||||
import installTemplate from './install.sh.tmpl?raw';
|
||||
|
||||
function escapeShell(str: string) {
|
||||
return str.replace(/(["\\$`])/g, '\\$1');
|
||||
function getReleaseRepo(): string | undefined {
|
||||
if (process.env.NADIR_RELEASE_REPO) {
|
||||
return process.env.NADIR_RELEASE_REPO;
|
||||
}
|
||||
|
||||
const RELEASE_REPO = escapeShell(repo);
|
||||
|
||||
const script = `#!/bin/sh
|
||||
set -e
|
||||
|
||||
RELEASE_REPO="${RELEASE_REPO}"
|
||||
|
||||
do_install() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "must be root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v curl >/dev/null 2>&1 || {
|
||||
echo "curl required" >&2
|
||||
exit 1
|
||||
const repositoryUrl = env.REPOSITORY_URL;
|
||||
if (repositoryUrl) {
|
||||
// e.g., https://tea.urania.dev/api/v1/repos/urania/nadir-agent/releases/latest -> https://tea.urania.dev/urania/nadir-agent
|
||||
const match = repositoryUrl.match(/(https?:\/\/[^/]+)\/api\/v1\/repos\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
return repositoryUrl;
|
||||
}
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
aarch64|arm64) arch=arm64 ;;
|
||||
*) echo "unsupported architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# safer repo parsing (no awk assumptions)
|
||||
host=$(echo "$RELEASE_REPO" | sed -E 's#(https?://[^/]+).*#\\1#')
|
||||
path=$(echo "$RELEASE_REPO" | sed -E 's#https?://[^/]+/##')
|
||||
|
||||
api="$host/api/v1/repos/$path/releases/latest"
|
||||
|
||||
echo "querying $api ..."
|
||||
asset_url=$(curl -fsSL "$api" \
|
||||
| grep -o '"browser_download_url":"[^"]*linux-'"$arch"'"' \
|
||||
| head -n1 \
|
||||
| cut -d'"' -f4)
|
||||
|
||||
if [ -z "$asset_url" ]; then
|
||||
echo "no asset found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "downloading $asset_url ..."
|
||||
curl -fL "$asset_url" -o /usr/local/bin/nadir.tmp
|
||||
|
||||
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
|
||||
chmod +x /usr/local/bin/nadir
|
||||
|
||||
/usr/local/bin/nadir install
|
||||
echo "done"
|
||||
return undefined;
|
||||
}
|
||||
|
||||
do_install
|
||||
`;
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const releaseRepo = getReleaseRepo();
|
||||
|
||||
if (!releaseRepo) {
|
||||
return new Response('install.sh is disabled: set server.release_repo in config.yaml', {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
},
|
||||
status: 404
|
||||
});
|
||||
}
|
||||
|
||||
const script = installTemplate.replace('__NADIR_RELEASE_REPO__', releaseRepo);
|
||||
|
||||
return new Response(script, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||
'Content-Type': 'text/x-shellscript; charset=utf-8'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Nadir bootstrap installer.
|
||||
#
|
||||
# Detects the host architecture, fetches the latest release asset from the
|
||||
# Gitea releases API, and installs it as a systemd service:
|
||||
#
|
||||
# curl -fsSL https://<existing-nadir-host>:<port>/install.sh | sudo sh
|
||||
#
|
||||
# The binary always comes from the Gitea release (not the source instance),
|
||||
# so the installed copy is guaranteed to be the latest tagged build,
|
||||
# independent of whatever version the serving host happens to be running.
|
||||
#
|
||||
# Everything is wrapped in do_install and invoked on the last line, the same
|
||||
# pattern get.docker.com uses: when piped via `curl | sh`, sh executes while
|
||||
# still receiving bytes from the network. Defining the whole body as a
|
||||
# function before running anything means sh has fully parsed the script
|
||||
# before do_install ever runs.
|
||||
|
||||
# Where to look for releases. Substituted at request time by the server
|
||||
# (cmd/server/server.go) so the script knows which Gitea instance owns
|
||||
# this deployment. Example: https://gitea.example.com/urania/nadir
|
||||
RELEASE_REPO="__NADIR_RELEASE_REPO__"
|
||||
|
||||
do_install() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "this script must be run as root (try piping through 'sudo sh')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "curl is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
aarch64|arm64) arch=arm64 ;;
|
||||
*) echo "unsupported architecture: $(uname -m) (only amd64 and arm64 are built)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
api="$RELEASE_REPO/releases/latest"
|
||||
api="${api%/}"
|
||||
# Convert https://host/owner/repo/releases/latest into Gitea API form.
|
||||
# Splits the URL once on '/' between host and path, inserts /api/v1/repos.
|
||||
host=$(echo "$RELEASE_REPO" | awk -F/ '{print $1"//"$3}')
|
||||
path=$(echo "$RELEASE_REPO" | awk -F/ '{print $4"/"$5}')
|
||||
api="$host/api/v1/repos/$path/releases/latest"
|
||||
|
||||
echo "querying $api ..."
|
||||
asset_url=$(curl -fsSL "$api" \
|
||||
| grep -o '"browser_download_url":"[^"]*linux-'"$arch"'"' \
|
||||
| head -n1 \
|
||||
| cut -d'"' -f4)
|
||||
|
||||
if [ -z "$asset_url" ]; then
|
||||
echo "no linux-$arch asset found in the latest release at $api" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "downloading $asset_url ..."
|
||||
curl -f --progress-bar -L "$asset_url" -o /usr/local/bin/nadir.tmp
|
||||
|
||||
asset_name=$(basename "$asset_url")
|
||||
|
||||
# Verify SHA-256: download the checksums file published alongside the binary
|
||||
# and confirm the hash matches. This catches CDN corruption and (together with
|
||||
# the HTTPS transport) makes tampered binaries detectable.
|
||||
sums_url="$host/api/v1/repos/$path/releases/latest"
|
||||
sums_asset_url=$(curl -fsSL "$sums_url" \
|
||||
| grep -o '"browser_download_url":"[^"]*sha256sums\.txt"' \
|
||||
| head -n1 \
|
||||
| cut -d'"' -f4)
|
||||
|
||||
if [ -n "$sums_asset_url" ]; then
|
||||
echo "verifying checksum ..."
|
||||
curl -fsSL "$sums_asset_url" -o /tmp/nadir-sha256sums.txt
|
||||
# Extract the expected hash for our asset and compare.
|
||||
expected=$(grep "$asset_name" /tmp/nadir-sha256sums.txt | awk '{print $1}')
|
||||
actual=$(sha256sum /usr/local/bin/nadir.tmp | awk '{print $1}')
|
||||
rm -f /tmp/nadir-sha256sums.txt
|
||||
if [ -z "$expected" ]; then
|
||||
echo "warning: sha256sums.txt does not contain a hash for $asset_name" >&2
|
||||
echo "proceeding without verification" >&2
|
||||
elif [ "$expected" != "$actual" ]; then
|
||||
echo "SHA-256 MISMATCH: expected $expected, got $actual" >&2
|
||||
echo "the downloaded binary may be corrupted or tampered with — aborting" >&2
|
||||
rm -f /usr/local/bin/nadir.tmp
|
||||
exit 1
|
||||
else
|
||||
echo "checksum OK ($actual)"
|
||||
fi
|
||||
else
|
||||
echo "warning: no sha256sums.txt in release — skipping verification" >&2
|
||||
fi
|
||||
|
||||
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
|
||||
chmod +x /usr/local/bin/nadir
|
||||
|
||||
echo "binary installed at /usr/local/bin/nadir"
|
||||
echo "installing as a systemd service ..."
|
||||
if [ -c /dev/tty ]; then
|
||||
/usr/local/bin/nadir install < /dev/tty
|
||||
else
|
||||
/usr/local/bin/nadir install
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "done. check status with: nadir status"
|
||||
}
|
||||
|
||||
do_install
|
||||
Reference in New Issue
Block a user