feat: implement drag-and-drop machine reordering with swapy and add new system administration management routes

This commit is contained in:
2026-06-23 01:20:45 +02:00
parent 856c9d3837
commit f8a17fa68a
28 changed files with 3128 additions and 395 deletions
+11 -10
View File
@@ -59,16 +59,17 @@ to Better Auth's `genericOAuth` plugin.
## Scripts
```sh
bun run dev # vite dev server
bun run build # production build (adapter-node -> build/)
bun run preview # preview the production build
bun run check # svelte-check
bun run lint # prettier + eslint
bun run format # prettier --write
bun run db:push # apply schema to the DB
bun run db:generate # generate migration from schema changes
bun run db:migrate # run pending migrations
bun run db:studio # drizzle-kit studio
bun run dev # vite dev server
bun run type:generate # generate typed client for nadir-agent
bun run build # production build (adapter-node -> build/)
bun run preview # preview the production build
bun run check # svelte-check
bun run lint # prettier + eslint
bun run format # prettier --write
bun run db:push # apply schema to the DB
bun run db:generate # generate migration from schema changes
bun run db:migrate # run pending migrations
bun run db:studio # drizzle-kit studio
```
---
+3
View File
@@ -13,6 +13,7 @@
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"swapy": "^1.0.5",
"uqr": "^0.1.3",
"valibot": "^1.4.1",
},
@@ -1111,6 +1112,8 @@
"sveltekit-superforms": ["sveltekit-superforms@2.30.1", "", { "dependencies": { "devalue": "^5.6.4", "memoize-weak": "^1.0.2", "ts-deepmerge": "^7.0.3" }, "optionalDependencies": { "@exodus/schemasafe": "^1.3.0", "@standard-schema/spec": "^1.1.0", "@typeschema/class-validator": "^0.3.0", "@valibot/to-json-schema": "^1.6.0", "@vinejs/vine": "^3.0.1", "arktype": "^2.2.0", "class-validator": "^0.14.4", "effect": "^3.21.0", "joi": "^17.13.3", "json-schema-to-ts": "^3.1.1", "superstruct": "^2.0.2", "typebox": "^1.1.6", "valibot": "^1.3.1", "yup": "^1.7.1", "zod": "^4.3.6", "zod-v3-to-json-schema": "^4.0.0" }, "peerDependencies": { "@sveltejs/kit": "1.x || 2.x", "svelte": "3.x || 4.x || >=5.0.0-next.51" } }, "sha512-wBzyqsE0idvEJWuNJ+HCiAtdxa7Z55GZ8jmtlVHJfonrk9bRYC49MoPaloYyFoYuU3QPy6Omna/Qzn1kaIkgew=="],
"swapy": ["swapy@1.0.5", "", {}, "sha512-XEzy5HCw7yESb7QajKTBJ+NA/BL0kAKlE7XhSMPZawF740X4ws/5CFtXRsVDQ+EztISC759a9+DJM9mxjz4hXg=="],
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
BIN
View File
Binary file not shown.
+204 -100
View File
@@ -1,25 +1,17 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"account": "Account",
"agent_update_failed": "Agent update did not complete - check `nadir logs` on the host",
"agent_update_started": "Updating agent... ({from} → {to})",
"agent_update_success": "Agent updated to {version}",
"agent_updates": "Agent outdated",
"already_have_account": "Already have an account?",
"appname": "NadiЯ",
"agent_updates":"Agent outdated",
"agent_update_started":"Updating agent... ({from} → {to})",
"agent_update_success":"Agent updated to {version}",
"agent_update_failed":"Agent update did not complete - check `nadir logs` on the host",
"machine_offline_title":"{name} is offline",
"machine_offline_description":"Could not reach the nadir-agent at {address}. The host may be down, the agent stopped, or the address is wrong.",
"machine_offline_code":"Error 502",
"machine_offline_node_you":"You",
"machine_offline_node_proxy":"Web UI",
"machine_offline_node_dest":"Agent",
"machine_offline_status_connected":"CONNECTED",
"machine_offline_status_unreachable":"UNREACHABLE",
"machine_offline_details":"Show error details",
"back_to_login": "Back to login",
"backup_code": "Backup code",
"backup_codes_notice": "Store these somewhere safe. Each code works once if you lose your device.",
"backup_codes_title": "Save your backup codes",
"cancel": "Cancel",
"check_your_email": "Check your email",
"code": "Code",
"confirm_password": "Confirm password",
@@ -69,14 +61,14 @@
"dashboard_network": "Network",
"dashboard_next": "Next",
"dashboard_none": "none",
"dashboard_nothing_to_show": "Nothing to show.",
"dashboard_not_synced": "Not synced",
"dashboard_nothing_to_show": "Nothing to show.",
"dashboard_os": "OS",
"dashboard_packages": "Packages",
"dashboard_pagination_info": "{start}{end} of {total}",
"dashboard_pause": "Pause auto-refresh",
"dashboard_prev": "Prev",
"dashboard_recent_activity": "Recent activity",
"dashboard_pause": "Pause auto-refresh",
"dashboard_refresh": "Refresh",
"dashboard_resume": "Resume auto-refresh",
"dashboard_search_placeholder": "Search",
@@ -97,7 +89,9 @@
"dashboard_uptime": "Uptime",
"dashboard_used_percent": "{used}% used",
"dashboard_utc": "UTC",
"delete": "Delete",
"download": "Download",
"edit": "Edit",
"email": "Email",
"email_complete_body": "Your account has been created. Choose a password to finish setting it up.",
"email_complete_button": "Set password",
@@ -121,29 +115,86 @@
"email_verify_ignore": "If you didn't create an account, you can safely ignore this email.",
"email_verify_subject": "Verify your email address",
"enter_password_to_continue": "Confirm your password to continue",
"errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999",
"errors_email_invalid": "Enter a valid email address",
"errors_generic": "An error occurred during this operation, please review Nadir Logs for more information.",
"errors_invalid_code": "Invalid or expired code, try again.",
"errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999",
"errors_non_empty": "This field is required",
"errors_not_found": "This item has not been found",
"errors_password_too_short": "Password must be at least {min} characters",
"errors_password_weak": "Use upper- and lower-case letters and at least one number.",
"errors_passwords_no_match": "Passwords do not match",
"errors_unauthenticated": "Unauthenticated",
"errors_username_too_short": "Username must be at least {min} characters",
"errors_wrong_credentials": "Wrong credentials, try again.",
"errors_unauthenticated": "Unauthenticated",
"errors_not_found": "This item has not been found",
"finish": "Finish",
"forbidden": "You are not allowed to this operation.",
"forgot_password": "Forgot your password?",
"forgot_password_description": "Enter your email and we'll send you a reset link.",
"forgot_password_title": "Forgot your password?",
"groups_add": "Add group",
"groups_add_member": "Add member",
"groups_add_member_action": "add",
"groups_add_member_description": "Adds this group to the user's supplementary set.",
"groups_add_member_no_results": "No matching users.",
"groups_add_member_search_placeholder": "Search user…",
"groups_add_member_title": "Add member to {name}",
"groups_create_description": "Adds a Unix group via groupadd.",
"groups_create_field_system": "System group",
"groups_create_title": "Create group",
"groups_created": "Group created",
"groups_delete_description": "Runs groupdel. Fails if it is the primary group of any existing user.",
"groups_delete_title": "Delete {name}?",
"groups_deleted": "Group deleted",
"groups_filter_show_system": "Show system groups",
"groups_filter_system_gid_hint": "(gid < 1000)",
"groups_member_added": "Added {username}",
"groups_member_removed": "Removed {username}",
"groups_members_description": "Supplementary members are managed via <code>usermod -G</code> on each user. Primary-group members (users whose primary gid is {gid}) appear below but cannot be removed from here.",
"groups_members_title": "Members",
"groups_nav_description": "Unix groups from /etc/group on this machine.",
"groups_nav_title": "Groups",
"groups_no_results": "No groups found.",
"groups_no_supplementary_members": "No supplementary members.",
"groups_not_found": "Group not found: {name}",
"groups_primary_empty": "None.",
"groups_primary_label": "Primary ({count})",
"groups_search_placeholder": "Search name or gid…",
"groups_supplementary_label": "Supplementary ({count})",
"home": "Home",
"invalid_reset_link": "This link is invalid or has expired.",
"language": "Language",
"login": "Login",
"login_social_description": "You have to login to use this platform. Use your favorite social or your credentials",
"login_with": "Login with <span class=capitalize>{social}</span>",
"logout": "Logout",
"machine_actions": "Server actions",
"machine_add": "Add server",
"machine_add_description": "Connect a new server to manage from this dashboard.",
"machine_address": "Address",
"machine_address_placeholder": "http://127.0.0.1:9999",
"machine_delete_confirm": "This permanently removes \"{name}\" from the database. The connected server is not touched.",
"machine_delete_title": "Delete server?",
"machine_edit": "Edit server",
"machine_edit_description": "Update the connection details for this server.",
"machine_name": "Name",
"machine_name_placeholder": "Production server",
"machine_none": "No servers yet.",
"machine_offline_code": "Error 502",
"machine_offline_description": "Could not reach the nadir-agent at {address}. The host may be down, the agent stopped, or the address is wrong.",
"machine_offline_details": "Show error details",
"machine_offline_node_dest": "Agent",
"machine_offline_node_proxy": "Web UI",
"machine_offline_node_you": "You",
"machine_offline_status_connected": "CONNECTED",
"machine_offline_status_unreachable": "UNREACHABLE",
"machine_offline_title": "{name} is offline",
"machine_save": "Add server",
"machine_save_edit": "Save changes",
"machine_search_placeholder": "Search servers…",
"machine_token": "Token",
"machine_token_keep": "Leave blank to keep the current token.",
"machine_token_placeholder": "Agent bearer token",
"manual_entry_key": "Can't scan? Enter this key in your authenticator app manually:",
"name": "Name",
"name_placeholder": "Jane Doe",
@@ -152,40 +203,27 @@
"nav_admin_config_desc": "Application-wide configuration.",
"nav_admin_users": "Users",
"nav_admin_users_desc": "Manage user accounts, roles and access.",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"machine_actions": "Server actions",
"machine_add": "Add server",
"machine_add_description": "Connect a new server to manage from this dashboard.",
"machine_delete_confirm": "This permanently removes \"{name}\" from the database. The connected server is not touched.",
"machine_delete_title": "Delete server?",
"machine_edit": "Edit server",
"machine_edit_description": "Update the connection details for this server.",
"machine_save_edit": "Save changes",
"machine_token_keep": "Leave blank to keep the current token.",
"machine_address": "Address",
"machine_address_placeholder": "http://127.0.0.1:9999",
"machine_name": "Name",
"machine_name_placeholder": "Production server",
"machine_none": "No servers yet.",
"machine_save": "Add server",
"machine_search_placeholder": "Search servers…",
"machine_token": "Token",
"machine_token_placeholder": "Agent bearer token",
"nav_dashboard_overview": "Overview",
"nav_dashboard_overview_desc": "System status at a glance.",
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {pages}",
"pagination_previous": "Previous",
"nav_system": "System",
"nav_system_datetime": "Date & Time",
"nav_system_datetime_desc": "Clock, timezone and time synchronisation.",
"nav_system_hostname": "Hostname",
"nav_system_hostname_desc": "Identify this machine on the network.",
"nav_system_localization": "Localization",
"nav_system_localization_desc": "Language, locale and region settings.",
"nav_system_power": "Power",
"nav_system_power_desc": "Reboot or power off the machine.",
"nav_users_groups": "Groups",
"nav_users_groups_desc": "Unix groups from /etc/group.",
"nav_users_system_users": "System users",
"nav_users_system_users_desc": "PAM/Unix accounts on this machine.",
"new_password": "New password",
"no_account": "No account yet?",
"or": "Or",
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {pages}",
"pagination_previous": "Previous",
"password": "Password",
"password_hint": "At least 8 characters, mixing upper- and lower-case letters and a number.",
"privacy_policy": "Privacy Policy",
@@ -194,14 +232,56 @@
"reset_password_action": "Update password",
"reset_password_description": "Choose a strong password you don't use anywhere else.",
"reset_password_title": "Set a new password",
"save": "Save",
"saved": "Saved",
"scan_qr": "Add this key to your authenticator app, then enter the generated code below.",
"send_reset_link": "Send reset link",
"settings": "Settings",
"setup_2fa_description": "Add an extra layer of security to your account.",
"setup_2fa_title": "Set up two-factor authentication",
"sign_up": "Sign up",
"sign_up_description": "Sign up with your email and a username.",
"sign_up_title": "Create your account",
"system_hostname_current": "Current hostname",
"system_hostname_invalid": "Hostname is invalid",
"system_locale_generate": "Generate new locale",
"system_locale_generate_button": "Generate",
"system_locale_generate_desc": "Install a new locale on the host. On Debian/Ubuntu/Arch this uncomments the entry in /etc/locale.gen and runs locale-gen; on RHEL/Fedora it uses localedef.",
"system_locale_generate_invalid": "Use the form xx_XX.UTF-8 (e.g. fr_FR.UTF-8)",
"system_locale_generate_placeholder": "e.g. ja_JP.UTF-8",
"system_locale_keymap": "Console keymap",
"system_locale_lang": "System locale (LANG)",
"system_locale_language": "Fallback language (LANGUAGE)",
"system_locale_language_add": "Add language",
"system_locale_language_button": "Save",
"system_locale_language_desc": "Set the fallback language priority list for system messages and translations (optional).",
"system_locale_language_empty": "No fallback language set.",
"system_locale_language_placeholder": "e.g. en_US:en",
"system_locale_no_keymap_found": "No keymap found.",
"system_locale_no_locale_found": "No locale found.",
"system_locale_search_keymap_placeholder": "Search keymap…",
"system_locale_search_locale_placeholder": "Search locale…",
"system_locale_x11": "X11 layout",
"system_power_confirm_description": "The machine will be unreachable while it shuts down. This cannot be undone from here.",
"system_power_confirm_poweroff_title": "Power off this machine?",
"system_power_confirm_reboot_title": "Reboot this machine?",
"system_power_poweroff": "Power off",
"system_power_reboot": "Reboot",
"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.",
"system_time_no_timezone_found": "No timezone found.",
"system_time_ntp": "Network time (NTP)",
"system_time_ntp_hint": "Automatically synchronize the clock with NTP servers.",
"system_time_ntp_not_synced": "Not synchronized",
"system_time_ntp_synced": "Synchronized",
"system_time_search_timezone_placeholder": "Search timezone…",
"system_time_timezone": "Timezone",
"terms_notice": "By clicking continue, you agree to our <a class='link' href={terms}>Terms of Service</a> and <a class='link' href={privacy}>Privacy Policy</a>.",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"theme_system": "System",
"trust_device": "Trust this device for 30 days",
"two_factor_description": "Enter the 6-digit code from your authenticator app.",
"two_factor_title": "Two-factor authentication",
@@ -209,76 +289,100 @@
"use_backup_code": "Use a backup code",
"username": "Username",
"username_placeholder": "admin",
"verification_sent": "We sent a verification link to {email}. Click it to activate your account.",
"verify": "Verify",
"verify_your_email": "You need to first verify your email address",
"welcome_back": "Welcome back",
"users_title": "Users",
"users_description": "Manage application users.",
"users_add": "Add User",
"users_create": "Create",
"users_edit": "Edit",
"users_delete": "Delete",
"users_ban": "Ban",
"users_unban": "Unban",
"users_search_placeholder": "Search by email…",
"users_rows_per_page": "Rows per page",
"users_role": "Role",
"users_role_user": "User",
"users_role_admin": "Admin",
"users_created_at": "Joined",
"users_status": "Status",
"users_banned": "Banned",
"users_active": "Active",
"users_action_set_password": "Set password",
"users_actions": "Actions",
"users_create_title": "Create user",
"users_create_description": "Add a new user to the system.",
"users_edit_title": "Edit user",
"users_edit_description": "Update user details.",
"users_delete_confirm_title": "Delete user?",
"users_delete_confirm_description": "This permanently removes the user. Optionally ban the email to prevent re-registration.",
"users_delete_ban_email": "Also ban this email",
"users_ban_reason": "Reason (optional)",
"users_no_results": "No users found.",
"users_page_of": "Page {page} of {total}",
"users_prev": "Previous",
"users_next": "Next",
"users_saved": "User saved",
"users_created": "User created",
"users_deleted": "User deleted",
"users_active": "Active",
"users_add": "Add User",
"users_ban": "Ban",
"users_ban_action_title": "Ban user?",
"cancel": "Cancel",
"users_ban_reason": "Reason (optional)",
"users_banned": "Banned",
"users_col_comment": "Comment",
"users_col_home": "Home",
"users_col_type": "Type",
"users_create": "Create",
"users_create_description": "Add a new user to the system.",
"users_create_field_comment": "Comment (GECOS)",
"users_create_field_create_home": "Create home directory",
"users_create_field_shell": "Shell",
"users_create_field_system": "System account",
"users_create_title": "Create user",
"users_created": "User created",
"users_created_at": "Joined",
"users_delete": "Delete",
"users_delete_ban_email": "Also ban this email",
"users_delete_confirm_description": "This permanently removes the user. Optionally ban the email to prevent re-registration.",
"users_delete_confirm_title": "Delete user?",
"users_delete_description": "Runs userdel on the host. This cannot be undone.",
"users_delete_field_remove_home": "Also remove home directory and mail spool",
"users_delete_title": "Delete {username}?",
"users_deleted": "User deleted",
"users_description": "Manage application users.",
"users_details": "Details",
"users_edit": "Edit",
"users_edit_description": "Update user details.",
"users_edit_title": "Edit user",
"users_filter": "Filter",
"users_filter_title": "Filter Users",
"users_filter_24h": "Last 24h",
"users_filter_30d": "Last 30 days",
"users_filter_7d": "Last 7 days",
"users_filter_active": "Active",
"users_filter_active_hint": "Users with a recent session.",
"users_filter_joined": "Joined",
"users_filter_any_time": "Any Time",
"users_filter_24h": "Last 24h",
"users_filter_7d": "Last 7 days",
"users_filter_30d": "Last 30 days",
"users_filter_date_range": "Date Range",
"users_filter_date_from": "From",
"users_filter_date_to": "To",
"users_filter_email_verified": "Email verified only",
"users_filter_online_only": "Online users only",
"users_filter_online_hint": "Users with an active session.",
"users_filter_show_banned": "Show banned users",
"users_filter_display": "Display",
"users_filter_reset": "Reset All",
"users_filter_count": "{n} filters active",
"users_filter_date_from": "From",
"users_filter_date_range": "Date Range",
"users_filter_date_to": "To",
"users_filter_display": "Display",
"users_filter_email_verified": "Email verified only",
"users_filter_joined": "Joined",
"users_filter_online_hint": "Users with an active session.",
"users_filter_online_only": "Online users only",
"users_filter_reset": "Reset All",
"users_filter_shell_only": "Login-capable shell only",
"users_filter_show_banned": "Show banned users",
"users_filter_show_system": "Show system users",
"users_filter_system_uid_hint": "(uid < 1000)",
"users_filter_title": "Filter Users",
"users_group_primary_badge": "(primary)",
"users_group_sys_badge": "sys",
"users_groups_title": "Groups",
"users_pam_groups_description": "Supplementary groups. Replaces the full set via <code>usermod -G</code>. Primary group is set at user creation and not editable here.",
"users_groups_updated": "Groups updated",
"users_invite": "Invite",
"users_invite_title": "Invite user",
"users_invite_description": "Send an email invitation. The user sets their own password.",
"users_invite_title": "Invite user",
"users_invited": "Invitation sent",
"users_nav_description": "PAM/Unix accounts from /etc/passwd on this machine.",
"users_nav_title": "System users",
"users_next": "Next",
"users_no_gecos": "No GECOS comment",
"users_no_groups": "No groups.",
"users_no_results": "No users found.",
"users_page_of": "Page {page} of {total}",
"users_pam_create_description": "Adds a PAM account via useradd. Password stays locked until you set one.",
"users_pam_search_placeholder": "Search username, GECOS, uid…",
"users_pending": "Pending",
"users_pending_expires": "Invite expires {date}",
"users_pending_no_invite": "Email not verified",
"users_prev": "Previous",
"users_primary_gid": "Primary GID",
"users_resend_invite": "Resend invite",
"settings": "Settings",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System",
"language": "Language"
"users_role": "Role",
"users_role_admin": "Admin",
"users_role_user": "User",
"users_rows_per_page": "Rows per page",
"users_saved": "User saved",
"users_search_placeholder": "Search by email…",
"users_set_password_description": "Piped to chpasswd over stdin; never appears in the process list.",
"users_set_password_title": "Set password — {username}",
"users_status": "Status",
"users_title": "Users",
"users_type_system": "system",
"users_type_user": "user",
"users_unban": "Unban",
"verification_sent": "We sent a verification link to {email}. Click it to activate your account.",
"verify": "Verify",
"verify_your_email": "You need to first verify your email address",
"welcome_back": "Welcome back"
}
+2
View File
@@ -12,6 +12,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"type:generate": "bunx openapi-typescript http://100.64.0.189:9999/openapi.json -o ./src/lib/server/nadir-agent/schema.d.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -74,6 +75,7 @@
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"swapy": "^1.0.5",
"uqr": "^0.1.3",
"valibot": "^1.4.1"
}
@@ -5,16 +5,22 @@
import { page } from '$app/state';
import * as Breadcrumb from '$lib/components/ui/breadcrumb';
import { m } from '$lib/paraglide/messages';
import { getMachine } from '$lib/remotes/machines.remote';
const machine = $derived(page.params.machineId ? getMachine(page.params.machineId) : null);
const LABELS: Record<string, () => string> = {
'/': m.home,
'/admin': m.nav_admin,
'/admin/config': m.nav_admin_config,
'/admin/users': m.nav_admin_users,
'/dashboard': m.dashboard,
'/system': m.nav_system,
'/system/date-time': m.nav_system_datetime,
'/system/localization': m.nav_system_localization
admin: m.nav_admin,
config: m.nav_admin_config,
dashboard: m.dashboard,
'date-time': m.nav_system_datetime,
groups: () => 'Groups',
hostname: m.nav_system_hostname,
localization: m.nav_system_localization,
power: m.nav_system_power,
system: m.nav_system,
users: m.nav_admin_users
};
const titleCase = (segment: string) =>
@@ -22,9 +28,14 @@
const crumbs = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean);
const machineName = machine?.current?.name;
const segments = parts.map((segment, i) => {
const href = '/' + parts.slice(0, i + 1).join('/');
return { href, label: LABELS[href]?.() ?? titleCase(segment) };
const label =
segment === page.params.machineId && machineName
? machineName
: (LABELS[segment]?.() ?? titleCase(segment));
return { href, label };
});
return [{ href: '/', label: LABELS['/']?.() ?? m.home() }, ...segments];
});
@@ -2,69 +2,15 @@
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import ServerIcon from '@lucide/svelte/icons/server';
import ShieldIcon from '@lucide/svelte/icons/shield';
import UsersIcon from '@lucide/svelte/icons/users';
import favicon from '$lib/assets/favicon.svg?raw';
import { m } from '$lib/paraglide/messages';
const data: {
navMain: {
icon: typeof ShieldIcon;
items: {
description: () => string;
title: () => string;
url: Pathname;
}[];
title: () => string;
url: Pathname;
}[];
} = {
navMain: [
{
icon: LayoutDashboardIcon,
items: [
{
description: m.nav_dashboard_overview_desc,
title: m.nav_dashboard_overview,
url: '/dashboard'
}
],
title: m.dashboard,
url: '/dashboard'
},
{
icon: ShieldIcon,
items: [
{
description: m.nav_admin_users_desc,
title: m.nav_admin_users,
url: '/admin/users'
},
{
description: m.nav_admin_config_desc,
title: m.nav_admin_config,
url: '/admin/config'
}
],
title: m.nav_admin,
url: '/admin'
},
{
icon: ServerIcon,
items: [
{
description: m.nav_system_datetime_desc,
title: m.nav_system_datetime,
url: '/system/date-time'
},
{
description: m.nav_system_localization_desc,
title: m.nav_system_localization,
url: '/system/localization'
}
],
title: m.nav_system,
url: '/system'
}
]
type NavSection = {
icon: typeof ShieldIcon;
items: { description: () => string; title: () => string; url: Pathname }[];
title: () => string;
url: Pathname;
};
</script>
@@ -84,7 +30,90 @@
const isMobile = new IsMobile();
const sidebar = useSidebar();
let mobileSection = $state<(typeof data.navMain)[number] | null>(null);
let mobileSection = $state<NavSection | null>(null);
const navMain = $derived.by<NavSection[]>(() => {
const machineId = page.params.machineId;
const sections: NavSection[] = [
{
icon: LayoutDashboardIcon,
items: [
{
description: m.nav_dashboard_overview_desc,
title: m.nav_dashboard_overview,
url: '/dashboard'
}
],
title: m.dashboard,
url: machineId ? `/dashboard/${machineId}`:'/dashboard'
},
{
icon: ShieldIcon,
items: [
{
description: m.nav_admin_users_desc,
title: m.nav_admin_users,
url: '/admin/users'
},
{
description: m.nav_admin_config_desc,
title: m.nav_admin_config,
url: '/admin/config'
}
],
title: m.nav_admin,
url: '/admin'
}
];
if (machineId) {
const base = `/dashboard/${machineId}/system`;
sections.splice(1,0,{
icon: ServerIcon,
items: [
{
description: m.nav_system_datetime_desc,
title: m.nav_system_datetime,
url: `${base}/date-time` as Pathname
},
{
description: m.nav_system_localization_desc,
title: m.nav_system_localization,
url: `${base}/localization` as Pathname
},
{
description: m.nav_system_hostname_desc,
title: m.nav_system_hostname,
url: `${base}/hostname` as Pathname
},
{
description: m.nav_system_power_desc,
title: m.nav_system_power,
url: `${base}/power` as Pathname
}
],
title: m.nav_system,
url: base as Pathname
});
sections.splice(2, 0, {
icon: UsersIcon,
items: [
{
description: m.nav_users_system_users_desc,
title: m.nav_users_system_users,
url: `/dashboard/${machineId}/users` as Pathname
},
{
description: m.nav_users_groups_desc,
title: m.nav_users_groups,
url: `/dashboard/${machineId}/users/groups` as Pathname
}
],
title: m.users_title,
url: `/dashboard/${machineId}/users` as Pathname
});
}
return sections;
});
// Navigating from any link in either drawer should drop the user on the page,
// not leave the rail + content sheets covering it.
@@ -99,14 +128,14 @@
}: { user: User } & ComponentProps<typeof Sidebar.Root> = $props();
const activeItem = $derived(
data.navMain.find((section) => page.url.pathname.startsWith(section.url)) ?? data.navMain[0]!
[...navMain].sort((a, b) => b.url.length - a.url.length).find((section) =>
page.url.pathname.startsWith(section.url)
) ?? navMain[0]!
);
</script>
{#snippet sectionContent(section: (typeof data.navMain)[number])}
{#if section.url === '/dashboard'}
<MachinesNav onnavigate={closeMobileSidebars} />
{:else}
{#snippet sectionContent(section: NavSection)}
{#each section.items as item (item.url)}
<a
href={resolve(item.url)}
@@ -122,6 +151,8 @@
</span>
</a>
{/each}
{#if section.url === '/dashboard'|| section.url === `/dashboard/${page.params.machineId}`}
<MachinesNav onnavigate={closeMobileSidebars} />
{/if}
{/snippet}
@@ -157,11 +188,11 @@
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupContent class="px-1.5 md:px-0">
<Sidebar.Menu class="space-y-2">
{#each data.navMain as item (item.url)}
<Sidebar.MenuItem>
<Sidebar.Group class="h-full">
<Sidebar.GroupContent class="px-1.5 h-full md:px-0">
<Sidebar.Menu class="space-y-2 h-full">
{#each navMain as item (item.url)}
<Sidebar.MenuItem class="last-of-type:mt-auto">
<Sidebar.MenuButton
tooltipContentProps={{ hidden: false }}
isActive={activeItem.url === item.url}
@@ -1,4 +1,5 @@
<script lang="ts">
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page } from '$app/state';
@@ -10,29 +11,74 @@
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { addMachine, listMachines } from '$lib/remotes/machines.remote';
import { auditLog, serverInfo, systemDetails } from '$lib/remotes/server.remote';
import { tick } from 'svelte';
import { addMachine, listMachines, reorderMachines } from '$lib/remotes/machines.remote';
import { untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { createSwapy } from 'swapy';
const info = $derived(serverInfo());
const details = $derived(systemDetails());
const audit = $derived(auditLog());
const id = $props.id();
const isMobile = new IsMobile();
let { onnavigate }: { onnavigate?: () => void } = $props();
// MediaQuery is false during SSR/first hydration; gate on mount so server and
// client agree on the Dialog branch, then switch to Drawer. Avoids hydration_mismatch.
let search = $state('');
let pageNum = $state(1);
let open = $state(false);
const PAGE_SIZE = 3;
let listEl: HTMLDivElement | undefined = $state();
let items: { address: string; id: string; name: null | string; }[] = $state([]);
// Recreate swapy whenever the set of ids changes (add/remove/page/search), so it
// binds to the fresh DOM. Reorders don't change the set, so swapy is left alone.
const idSet = $derived([...items.map((i) => i.id)].sort().join('|'));
$effect(() => {
if (!listEl || !idSet) return;
const inst = createSwapy(listEl, {
animation: 'dynamic',
autoScrollOnDrag: true,
dragAxis: 'y'
});
inst.onSwapEnd(async ({ hasChanged }) => {
if (!hasChanged) return;
// slots are fixed (= items order); read which item now sits in each slot.
const map = inst.slotItemMap().asObject;
const ids = untrack(() => items)
.map((mc) => map[mc.id])
.filter((x): x is string => !!x);
try {
inst.enable(false)
await reorderMachines({ ids, startIndex: (pageNum - 1) * PAGE_SIZE });
} catch (error) {
console.error(error);
toast.error(m.errors_generic());
}finally{
inst.enable(true)
}
});
return () => inst.destroy();
});
const onSearch = () => {
pageNum = 1;
};
const selectedId = $derived(page.params.machineId);
const machines = $derived(listMachines({ page: pageNum, search }));
let pageInfo: { page: number; pages: number } = $state({ page: 1, pages: 1 });
$effect(() => {
machines.then((data) => {
pageInfo = { page: data.page, pages: data.pages };
untrack(() => {
// Only replace items when the *set* changes. A reorder leaves the set
// identical — keep our array so swapy's DOM arrangement isn't clobbered.
const cur = [...items.map((i) => i.id)].sort().join('|');
const next = [...data.items.map((i) => i.id)].sort().join('|');
if (cur !== next) items = data.items;
});
});
});
</script>
{#snippet addForm()}
@@ -134,51 +180,58 @@
{/if}
</div>
<svelte:boundary>
{@const data = await machines}
<div class="flex flex-col">
{#each data.items as machine (machine.id)}
<a
href={resolve(`/dashboard/[machineId]`, { machineId: machine.id })}
onclick={async () => {
onnavigate?.();
await tick().then(async () => {
await details.refresh();
await audit.refresh();
await info.refresh();
});
}}
<div bind:this={listEl} class="flex flex-col">
{#each items as machine (machine.id)}
<div data-swapy-slot={machine.id}>
<div
data-swapy-item={machine.id}
class="group/item flex items-center justify-between border-b last:border-b-0 hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground rounded-l-2xl transition-colors"
data-active={selectedId === machine.id}
class="group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 border-b p-4 text-sm leading-tight transition-all last:border-b-0"
>
<span class="font-medium transition-all">{machine.name}</span>
<span
class="text-foreground group-hover/link:text-tertiary-foreground group-data-[active=true]/link:text-tertiary-foreground transition-all text-xs"
>{machine.address}</span
<a
href={resolve(`/dashboard/[machineId]`, { machineId: machine.id })}
onclick={async () => {
onnavigate?.();
}}
draggable="false"
data-swapy-no-drag
class="flex-1 flex flex-col items-start gap-1 p-4 text-sm leading-tight transition-all"
>
</a>
{:else}
<p class="text-muted-foreground p-4 text-sm">{m.machine_none()}</p>
{/each}
</div>
{#if data.pages > 1}
<div class="flex items-center justify-between gap-2 p-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => (pageNum = data.page - 1)}>{m.pagination_previous()}</Button
>
<span class="text-muted-foreground text-xs"
>{m.pagination_page_of({ page: data.page, pages: data.pages })}</span
>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.pages}
onclick={() => (pageNum = data.page + 1)}>{m.pagination_next()}</Button
>
<span class="font-medium transition-all">{machine.name}</span>
<span
class="text-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground transition-all text-xs"
>{machine.address}</span
>
</a>
<div
data-swapy-handle
class="text-muted-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground cursor-grab p-4 px-3 flex items-center justify-center"
>
<GripVerticalIcon class="size-4" />
</div>
</div>
</div>
{/if}
</svelte:boundary>
{:else}
<p class="text-muted-foreground p-4 text-sm">{m.machine_none()}</p>
{/each}
</div>
{#if pageInfo.pages > 1}
<div class="flex items-center justify-between gap-2 p-2">
<Button
variant="outline"
size="sm"
disabled={pageInfo.page <= 1}
onclick={() => (pageNum = pageInfo.page - 1)}>{m.pagination_previous()}</Button
>
<span class="text-muted-foreground text-xs"
>{m.pagination_page_of({ page: pageInfo.page, pages: pageInfo.pages })}</span
>
<Button
variant="outline"
size="sm"
disabled={pageInfo.page >= pageInfo.pages}
onclick={() => (pageNum = pageInfo.page + 1)}>{m.pagination_next()}</Button
>
</div>
{/if}
+1
View File
@@ -16,6 +16,7 @@ const EnvSchema = v.object({
v.transform((v) => v === 'true')
),
ORIGIN: v.pipe(v.optional(v.string(), 'http://localhost:5173')),
REPOSITORY_URL: v.pipe(v.string(), v.nonEmpty()),
SMTP_FROM: v.pipe(v.optional(v.string())),
SMTP_HOST: v.pipe(v.optional(v.string())),
SMTP_PASS: v.pipe(v.optional(v.string())),
+34 -6
View File
@@ -1,18 +1,17 @@
import { error } from '@sveltejs/kit';
import { form, query } from '$app/server';
import { command, form, query } from '$app/server';
import { v } from '$lib';
import { machineDeleteSchema, machineEditSchema, machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { machines } from '$lib/server/db/schema';
import { asc, count, eq, like, or } from 'drizzle-orm';
import { asc, count, eq, like, or, sql } from 'drizzle-orm';
const PAGE_SIZE = 10;
export const listMachines = query(
v.object({ page: v.optional(v.number(), 1), search: v.optional(v.string(), '') }),
async ({ page, search }) => {
// Never select `token` — it stays server-side only.
const where = search
? or(like(machines.name, `%${search}%`), like(machines.address, `%${search}%`))
: undefined;
@@ -21,7 +20,7 @@ export const listMachines = query(
.select({ address: machines.address, id: machines.id, name: machines.name })
.from(machines)
.where(where)
.orderBy(asc(machines.name))
.orderBy(sql`${machines.order} IS NULL`, asc(machines.order), asc(machines.name))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE),
db.select({ total: count() }).from(machines).where(where)
@@ -31,14 +30,43 @@ export const listMachines = query(
}
);
export const reorderMachines = command(
v.object({ ids: v.array(v.string()), startIndex: v.optional(v.number(), 0) }),
async ({ ids, startIndex }) => {
// ponytail: two-phase to satisfy UNIQUE(order); negative offsets avoid collision with existing.
await db.transaction(async (tx) => {
for (const id of ids) {
await tx
.update(machines)
.set({ order: -1 - Math.floor(Math.random() * 1_000_000_000) })
.where(eq(machines.id, id));
}
for (const [i, mid] of ids.entries()) {
await tx
.update(machines)
.set({ order: startIndex + i })
.where(eq(machines.id, mid));
}
});
return { ok: true };
}
);
export const getMachine = query(v.string(), async (id) => {
const row = await db
.select({ address: machines.address, id: machines.id, name: machines.name })
.from(machines)
.where(eq(machines.id, id))
.limit(1);
return row[0] ?? null;
});
export const addMachine = form(machineSchema, async ({ address, name, token }) => {
await db.insert(machines).values({ address, name, token });
return { ok: true };
});
export const updateMachine = form(machineEditSchema, async ({ address, id, name, token }) => {
// Only patch the fields the user actually changed — blanks mean "keep current"
// (the form pre-fills name/address but doesn't push them through the form state).
const patch: { address?: string; name?: string; token?: string } = {};
if (name) patch.name = name;
if (address) {
+110
View File
@@ -0,0 +1,110 @@
import { error } from '@sveltejs/kit';
import { command, query } from '$app/server';
import { v } from '$lib';
import { m } from '$lib/paraglide/messages';
import { nadirForMachine } from './utils';
export const listPamUsers = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/users');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data.users ?? [];
});
export const createPamUser = command(
v.object({
comment: v.optional(v.string()),
create_home: v.optional(v.boolean()),
home: v.optional(v.string()),
machineId: v.string(),
shell: v.optional(v.string()),
system: v.optional(v.boolean()),
username: v.string()
}),
async (body) => {
const nadir = await nadirForMachine(body.machineId);
const { error: err } = await nadir.POST('/api/users', { body });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamUsers(body.machineId).refresh();
}
);
export const deletePamUser = command(
v.object({ machineId:v.string(),remove_home: v.optional(v.boolean()), username: v.string() }),
async ({ machineId, remove_home, username }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/users/{username}', {
params: { path: { username }, query: { remove_home } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamUsers(machineId).refresh();
}
);
export const setPamUserPassword = command(
v.object({ machineId:v.string(),password: v.string(), username: v.string() }),
async ({machineId, password, username }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/users/{username}/password', {
body: { password },
params: { path: { username } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
}
);
export const getPamUser = query(v.object({machineId:v.string(), username:v.string()}), async ({machineId,username}) => {
const nadir = await nadirForMachine(machineId,);
const { data, error: err } = await nadir.GET('/api/users/{username}', {
params: { path: { username } }
});
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
});
export const setPamUserGroups = command(
v.object({ groups: v.array(v.string()),machineId:v.string(), username: v.string() }),
async ({ groups, machineId, username }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/users/{username}/groups', {
body: { groups },
params: { path: { username } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamGroups(machineId,).refresh();
}
);
export const listPamGroups = query(v.string(),async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/groups');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data.groups ?? [];
});
export const createPamGroup = command(
v.object({
gid: v.optional(v.number()),
machineId: v.string(),
name: v.string(),
system: v.optional(v.boolean())
}),
async (body) => {
const nadir = await nadirForMachine(body.machineId);
const { error: err } = await nadir.POST('/api/groups', { body });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamGroups(body.machineId).refresh();
}
);
export const deletePamGroup = command(v.object({group:v.string(), machineId:v.string()}), async ({group, machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/groups/{group}', {
params: { path: { group } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamGroups(machineId).refresh();
});
+8 -13
View File
@@ -1,15 +1,15 @@
import { error } from '@sveltejs/kit';
import { command, getRequestEvent, query } from '$app/server';
import { v } from '$lib';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
export const serverInfo = query(async () => {
export const serverInfo = query(v.string(),async (machineId) => {
const {
locals: { user },
params: { machineId }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
@@ -39,10 +39,9 @@ export const serverInfo = query(async () => {
}
});
export const auditLog = query(v.optional(v.number(), 20), async (limit) => {
export const auditLog = query(v.object({limit:v.optional(v.number(), 20),machineId:v.string()}), async ({limit,machineId}) => {
const {
locals: { user },
params: { machineId }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
@@ -58,13 +57,12 @@ export const auditLog = query(v.optional(v.number(), 20), async (limit) => {
}
});
// ponytail: in-memory cache, 10 min; switch to Redis/KV if we ever run >1 node
let latestCache: { at: number; tag: string | null } | null = null;
let latestCache: { at: number; tag: null | string } | null = null;
export const latestAgentRelease = query(async () => {
if (latestCache && Date.now() - latestCache.at < 10 * 60_000) return latestCache.tag;
if (latestCache && Date.now() - latestCache.at < 60_000) return latestCache.tag;
try {
const r = await fetch(
'https://tea.urania.dev/api/v1/repos/urania/nadir-agent/releases/latest'
env.REPOSITORY_URL
);
const tag = r.ok ? (((await r.json()) as { tag_name?: string }).tag_name ?? null) : null;
latestCache = { at: Date.now(), tag };
@@ -74,10 +72,9 @@ export const latestAgentRelease = query(async () => {
}
});
export const updateAgent = command(async () => {
export const updateAgent = command(v.string(),async (machineId) => {
const {
locals: { user },
params: { machineId }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
@@ -88,11 +85,9 @@ export const updateAgent = command(async () => {
const { error: err } = await nadir.POST('/api/update');
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
});
export const systemDetails = query(async () => {
export const systemDetails = query(v.string(),async (machineId,) => {
const {
locals: { user },
params: { machineId }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
+138
View File
@@ -0,0 +1,138 @@
import { error } from '@sveltejs/kit';
import { command, getRequestEvent, query } from '$app/server';
import { v } from '$lib';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
import { systemDetails } from './server.remote';
async function nadirForMachine(machineId:string) {
const {
locals: { user },
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
return getClient(machine.address, token);
}
export const systemTime = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/time');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
});
export const systemLocale = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/locale');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
});
export const listTimezones = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/timezones');
return data?.timezones ?? [];
});
export const listLocales = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/locales');
return data?.locales ?? [];
});
export const setTimezone = command(v.object({machineId:v.string(), timezone:v.string()}), async ({machineId,timezone}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/timezone', { body: { timezone } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemTime(machineId).refresh();
await systemDetails(machineId).refresh();
});
export const setNtp = command(v.object({enabled:v.boolean(), machineId:v.string()}), async ({enabled,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/ntp', { body: { enabled } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemTime(machineId).refresh();
await systemDetails(machineId).refresh();
});
export const setTime = command(v.object({machineId:v.string(), time:v.string()}), async ({machineId,time}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/time', { body: { time } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemTime(machineId).refresh();
});
export const systemHostname = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/hostname');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
});
export const setHostname = command(v.object({hostname:v.string(), machineId:v.string()}), async ({hostname,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemHostname(machineId).refresh();
});
export const listKeymaps = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/keymaps');
const d = data as { keymaps?: null | string[]; reason?: string } | undefined;
return { keymaps: d?.keymaps ?? [], reason: d?.reason ?? '' };
});
export const setKeymap = command(v.object({keymap: v.string(),machineId:v.string()}), async ({keymap,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/keymap', { body: { keymap } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemLocale(machineId).refresh();
await systemDetails(machineId).refresh();
});
export const powerOff = command(v.object({machineId:v.string(), when:v.optional(v.string(),"")}), async ({machineId,when}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/poweroff', { body: { when } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
});
export const reboot = command(v.object({machineId:v.string(), when:v.optional(v.string(), '')}), async ({machineId,when}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/reboot', { body: { when } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
});
export const setLocale = command(
v.object({
lang: v.string(),
language: v.optional(v.string()),
machineId:v.string()
}),
async ({ lang,language , machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale', {
body: { lang, language: language || undefined }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemLocale(machineId).refresh();
await systemDetails(machineId).refresh();
}
);
export const generateLocale = command(v.object({locale:v.string(), machineId:v.string()}), async ({locale,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listLocales(machineId).refresh();
});
+18
View File
@@ -0,0 +1,18 @@
import { error } from "@sveltejs/kit";
import { getRequestEvent } from "$app/server";
import { m } from "$lib/paraglide/messages";
import { db } from "$lib/server/db";
import { decryptValue } from "$lib/server/db/custom-types";
import { getClient } from "$lib/server/nadir-agent/client";
export const nadirForMachine = async(machineId:string) => {
const {
locals: { user },
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
return getClient(machine.address, token);
}
+134 -2
View File
@@ -784,6 +784,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/system/locale/generate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Generate (install) a new locale
* @description Generates a locale so it becomes available for use with set-locale. On Debian/Ubuntu/Arch this uncomments the entry in /etc/locale.gen and runs locale-gen; on RHEL/Fedora it uses localedef. Idempotent: if the locale is already generated, returns 200 immediately.
*/
post: operations["system-generate-locale"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/system/locales": {
parameters: {
query?: never;
@@ -1360,6 +1380,19 @@ export interface components {
*/
pass: number;
};
GenerateLocaleInputBody: {
/**
* Format: uri
* @description A URL to the JSON Schema for this object.
* @example https://example.com/schemas/GenerateLocaleInputBody.json
*/
readonly $schema?: string;
/**
* @description Locale to generate (e.g. fr_FR.UTF-8)
* @example fr_FR.UTF-8
*/
locale: string;
};
Group: {
/**
* Format: uri
@@ -1380,7 +1413,7 @@ export interface components {
* @example wheel
*/
name: string;
/** @description True for system groups (gid < 1000) */
/** @description True for system groups (g 1000) */
system: boolean;
};
HealthOutputBody: {
@@ -1574,6 +1607,8 @@ export interface components {
readonly $schema?: string;
/** @description Available virtual console keymaps */
keymaps: string[] | null;
/** @description When keymaps is empty, why: e.g. "kbd not installed on this server" */
reason?: string;
};
ListFstabOutputBody: {
/**
@@ -1695,6 +1730,11 @@ export interface components {
* @example it_IT.UTF-8
*/
lang: string;
/**
* @description Fallback language list (LANGUAGE)
* @example en_US:
*/
language: string;
/**
* @description Virtual console keymap
* @example it
@@ -2081,6 +2121,11 @@ export interface components {
* @example it_IT.UTF-8
*/
lang: string;
/**
* @description Fallback language list (LANGUAGE)
* @example en_US:
*/
language?: string;
};
SetNTPInputBody: {
/**
@@ -2255,7 +2300,7 @@ export interface components {
* @example /bin/bash
*/
shell: string;
/** @description True for system accounts (uid < 1000) */
/** @description True for system accounts (u 1000) */
system: boolean;
/**
* Format: int64
@@ -5259,6 +5304,84 @@ export interface operations {
};
};
};
"system-generate-locale": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["GenerateLocaleInputBody"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StatusOutputBody"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
};
};
"system-list-locales": {
parameters: {
query?: never;
@@ -5835,6 +5958,15 @@ export interface operations {
"application/json": components["schemas"]["StatusOutputBody"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Unauthorized */
401: {
headers: {
+4 -2
View File
@@ -17,17 +17,19 @@
let { children } = $props();
const user = $derived(getUser());
let showSidebar =$derived( (cU:null|User)=> cU && (page.url.pathname.startsWith('/dashboard')||page.url.pathname.startsWith('/admin')))
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<Sidebar.Provider style="--sidebar-width: 350px;">
{@const currentUser = await user}
{#if currentUser}
{@const show = currentUser && showSidebar(currentUser)}
{#if show }
<AppSidebar user={currentUser} />
{/if}
<Sidebar.Inset>
{#if await user}
{#if show}
<header
class="bg-background sticky top-0 flex shrink-0 items-center gap-2 border-b p-4 h-15 z-50"
>
-2
View File
@@ -1,3 +1 @@
// Forces hooks.server.ts to run on every /admin navigation (client-side router
// skips the server otherwise when no server load is defined).
export const load = () => {};
+121 -114
View File
@@ -194,11 +194,17 @@
}
</script>
<div class="flex flex-col gap-4 p-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">{m.users_title()}</h1>
<p class="text-muted-foreground text-sm">{m.users_description()}</p>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
{m.users_title()}
</h1>
<p class="text-muted-foreground text-sm truncate">
{m.users_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
@@ -214,10 +220,10 @@
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0" align="end">
<div class="border-b p-4">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="grid grid-cols-2 gap-3 p-4">
<div class="grid grid-cols-2 gap-3 p-2">
<div class="flex flex-col gap-1.5">
<Label class="flex items-center gap-1 text-xs"
>{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" /></Label
@@ -239,7 +245,7 @@
</NativeSelect>
</div>
</div>
<div class="border-t p-4">
<div class="border-t p-2">
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
@@ -268,7 +274,7 @@
</div>
</div>
</div>
<div class="flex flex-col gap-3 border-t p-4">
<div class="flex flex-col gap-3 border-t p-2">
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_email_verified()}</span>
<Checkbox
@@ -302,7 +308,7 @@
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-4">
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
@@ -328,7 +334,7 @@
</datalist>
</div>
</div>
<div class="flex items-center justify-between border-t p-3">
<div class="flex items-center justify-between border-t p-2">
<Button variant="ghost" size="sm" onclick={resetFilters}
>{m.users_filter_reset()}</Button
>
@@ -365,7 +371,7 @@
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.users_search_placeholder()}
value={search}
@@ -373,113 +379,114 @@
class="max-w-sm"
/>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] 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.users_role()}</Table.Head>
<Table.Head>{m.users_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if usersQuery.loading && !data.users.length}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each data.users as u (u.id)}
<Table.Row>
<Table.Cell class="font-medium">{u.name}</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
<Table.Cell>{u.email}</Table.Cell>
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
</Badge>
</Table.Cell>
<Table.Cell>
{#if u.banned}
<Badge variant="destructive">{m.users_banned()}</Badge>
{:else if !u.emailVerified}
<Badge
variant="secondary"
title={u.inviteExpiresAt
? m.users_pending_expires({
date: new Date(u.inviteExpiresAt).toLocaleString()
})
: m.users_pending_no_invite()}
>
{m.users_pending()}
</Badge>
{:else}
<Badge variant="outline">{m.users_active()}</Badge>
{/if}
</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={() => openEdit(u)}
>{m.users_edit()}</DropdownMenu.Item
>
{#if !u.emailVerified}
<DropdownMenu.Item onclick={() => doResendInvite(u)}
>{m.users_resend_invite()}</DropdownMenu.Item
>
{#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] 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}
<DropdownMenu.Item onclick={() => toggleBan(u)}>
{u.banned ? m.users_unban() : m.users_ban()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
{m.users_delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_role()}</Table.Head>
<Table.Head>{m.users_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if usersQuery.loading && !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{:else if !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each data.users as u (u.id)}
<Table.Row>
<Table.Cell class="font-medium">{u.name}</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
<Table.Cell>{u.email}</Table.Cell>
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
</Badge>
</Table.Cell>
<Table.Cell>
{#if u.banned}
<Badge variant="destructive">{m.users_banned()}</Badge>
{:else if !u.emailVerified}
<Badge
variant="secondary"
title={u.inviteExpiresAt
? m.users_pending_expires({
date: new Date(u.inviteExpiresAt).toLocaleString()
})
: m.users_pending_no_invite()}
>
{m.users_pending()}
</Badge>
{:else}
<Badge variant="outline">{m.users_active()}</Badge>
{/if}
</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={() => openEdit(u)}
>{m.users_edit()}</DropdownMenu.Item
>
{#if !u.emailVerified}
<DropdownMenu.Item onclick={() => doResendInvite(u)}
>{m.users_resend_invite()}</DropdownMenu.Item
>
{/if}
<DropdownMenu.Item onclick={() => toggleBan(u)}>
{u.banned ? m.users_unban() : m.users_ban()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
{m.users_delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
+18 -15
View File
@@ -18,6 +18,7 @@
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Trunk from '$lib/components/blocks/trunk/trunk.svelte';
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
@@ -45,6 +46,7 @@
import { Input } from '$lib/components/ui/input';
import { Progress } from '$lib/components/ui/progress';
import * as Select from '$lib/components/ui/select';
import { Spinner } from '$lib/components/ui/spinner';
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
@@ -57,9 +59,10 @@
} from '$lib/remotes/server.remote';
import { toast } from 'svelte-sonner';
const info = $derived(serverInfo());
const audit = $derived(auditLog());
const details = $derived(systemDetails());
const machineId = $derived(page.params.machineId!)
const info = $derived(serverInfo(machineId));
const audit = $derived(auditLog({limit:undefined,machineId}));
const details = $derived(systemDetails(machineId));
const latest = $derived(latestAgentRelease());
const intervals = $derived([
{ label: m.dashboard_interval_second(), value: '1' },
@@ -76,19 +79,16 @@
const intervalLabel = $derived(intervals.find((i) => i.value === syncInterval)?.label);
const poll = async () => {
console.log('refreshed poll');
await serverInfo().refresh();
await auditLog().refresh();
await serverInfo(machineId).refresh();
await auditLog({limit:undefined, machineId}).refresh();
};
const refreshAll = async () => {
console.log('refreshed all');
await poll();
await systemDetails().refresh();
await systemDetails(machineId).refresh();
};
let polling = $state(true);
// $effect re-runs when rate or polling toggle, so changing the selector takes
// effect immediately and pausing actually stops the timer.
$effect(() => {
if (!polling || intervalMs <= 0) return;
const id = setInterval(poll, intervalMs);
@@ -217,7 +217,7 @@
{sys.$db.name}
</h1>
<p class="text-muted-foreground text-sm truncate">
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$agent_version} | {sys.$db.address}
</p>
</div>
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
@@ -295,7 +295,7 @@
const from = agentVersion ?? '?';
const to = latestTag ?? '?';
try {
await updateAgent();
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.
@@ -387,8 +387,8 @@
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
<div class="col-span-2">
<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">
<StoragePanel items={sys.disks ?? []} />
</div>
@@ -402,7 +402,7 @@
/>
</div>
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
<div class="grid items-start gap-4 xl:grid-cols-3 p-4 py-2">
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
<NetworkPanel items={sys.network_interfaces ?? []} />
<TemperaturePanel items={sys.temperatures ?? []} />
@@ -518,6 +518,9 @@
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{#snippet pending()}
<Spinner class="size-24 m-auto"/>
{/snippet}
</svelte:boundary>
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ClockIcon from '@lucide/svelte/icons/clock';
import GlobeIcon from '@lucide/svelte/icons/globe';
import PowerIcon from '@lucide/svelte/icons/power';
import ServerIcon from '@lucide/svelte/icons/server';
import {resolve} from '$app/paths'
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const base = $derived(`/dashboard/${page.params.machineId}/system`);
</script>
<div class="mx-auto flex w-full max-w-3xl 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">
<a href={resolve(`${base}/date-time` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ClockIcon class="size-5" />
<Card.Title>{m.nav_system_datetime()}</Card.Title>
</div>
<Card.Description>{m.nav_system_datetime_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/hostname` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ServerIcon class="size-5" />
<Card.Title>{m.nav_system_hostname()}</Card.Title>
</div>
<Card.Description>{m.nav_system_hostname_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/localization` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<GlobeIcon class="size-5" />
<Card.Title>{m.nav_system_localization()}</Card.Title>
</div>
<Card.Description>{m.nav_system_localization_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/power` as Pathname)} 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>
@@ -0,0 +1,164 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
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 {
listTimezones,
setNtp,
setTime,
setTimezone,
systemTime
} from '$lib/remotes/system.remote';
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();
let tzOpen = $state(false);
let saving = $state(false);
// ponytail: builds an RFC3339 UTC string from a datetime-local value (treated as local).
function rfc3339FromLocal(v: string) {
return new Date(v).toISOString().replace(/\.\d{3}Z$/, 'Z');
}
async function withSaving<T>(fn: () => Promise<T>) {
saving = true;
try {
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
} finally {
saving = false;
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const t = await time}
{@const zones = await tzs}
<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>
<Popover.Root bind:open={tzOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={tzOpen}
class="w-full justify-between sm:w-80"
{...props}
>
{t.timezone}
<ChevronsUpDownIcon class="size-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_time_search_timezone_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_time_no_timezone_found()}</Command.Empty>
{#each zones as z (z)}
<Command.Item
value={z}
onSelect={async () => {
tzOpen = false;
if (z === t.timezone) return;
await withSaving(() => setTimezone({machineId,timezone:z}));
}}
>
<CheckIcon
class={'mr-2 size-4 ' + (z === t.timezone ? 'opacity-100' : 'opacity-0')}
/>
{z}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</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>
<Card.Description>{m.system_time_manual_hint()}</Card.Description>
</Card.Header>
<Card.Content>
<form
class="flex flex-col gap-2 sm:flex-row sm:items-end"
onsubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const v = String(fd.get('time') ?? '').trim();
if (!v) return;
await withSaving(() => setTime({machineId,time:rfc3339FromLocal(v)}));
}}
>
<div class="flex grow flex-col gap-1.5">
<Label for={formId + 'time'}>{m.system_time_current()}</Label>
<Input
id={formId + 'time'}
name="time"
type="datetime-local"
step="1"
disabled={t.ntp}
/>
</div>
<Button type="submit" disabled={t.ntp || saving}>{m.save()}</Button>
</form>
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const host = $derived(systemHostname(machineId));
const formId = $props.id();
let saving = $state(false);
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
const HOSTNAME_RE = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const h = await host}
<Card.Root>
<Card.Header>
<Card.Title>{m.system_hostname_current()}</Card.Title>
<Card.Description>{h.hostname}</Card.Description>
</Card.Header>
<Card.Content>
<form
class="flex flex-col gap-2 sm:flex-row sm:items-end"
onsubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const v = String(fd.get('hostname') ?? '').trim();
if (!HOSTNAME_RE.test(v)) {
toast.error(m.system_hostname_invalid());
return;
}
if (v === h.hostname) return;
saving = true;
try {
await setHostname({hostname:v,machineId,});
toast.success(m.saved());
} catch (err) {
toast.error((err as Error).message || 'Error');
} finally {
saving = false;
}
}}
>
<div class="flex grow flex-col gap-1.5">
<Label for={formId + 'hn'}>{m.nav_system_hostname()}</Label>
<Input
id={formId + 'hn'}
name="hostname"
value={h.hostname}
required
pattern="[A-Za-z0-9.-]+"
maxlength={253}
/>
</div>
<Button type="submit" disabled={saving}>{m.save()}</Button>
</form>
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
@@ -0,0 +1,296 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
import { page } from '$app/state';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import {
generateLocale,
listKeymaps,
listLocales,
setKeymap,
setLocale,
systemLocale
} from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const locale = $derived(systemLocale(machineId));
const locales = $derived(listLocales(machineId));
const keymaps = $derived(listKeymaps(machineId));
let open = $state(false);
let kmOpen = $state(false);
let langOpen = $state(false);
let saving = $state(false);
let newLocale = $state('');
let langList = $state<string[]>([]);
let serverLanguage = $state('');
$effect(() => {
locale.then((data) => {
if (data && data.language !== serverLanguage) {
serverLanguage = data.language || '';
langList = serverLanguage ? serverLanguage.split(':').filter(Boolean) : [];
}
});
});
const languageDirty = $derived(langList.join(':') !== serverLanguage);
// glibc locale: language[_TERRITORY][.codeset][@modifier] — require codeset for clarity.
const LOCALE_RE = /^[a-z]{2,3}_[A-Z]{2}\.[A-Za-z0-9-]+(@[A-Za-z0-9]+)?$/;
const localeValid = $derived(LOCALE_RE.test(newLocale.trim()));
async function pick(fn: () => Promise<unknown>) {
if (saving) return;
saving = true;
try {
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
} finally {
saving = false;
}
}
async function handleSetLanguage() {
const l = await locale;
await setLocale({ lang: l.lang,language: langList.join(':'), machineId });
serverLanguage = '';
}
async function handleGenerate() {
const loc = newLocale.trim();
if (!LOCALE_RE.test(loc)) {
toast.error(m.system_locale_generate_invalid());
return;
}
await generateLocale({locale:loc,machineId});
newLocale = '';
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const l = await locale}
{@const list = await locales}
{@const kms = await keymaps}
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_lang()}</Card.Title>
<Card.Description>{l.lang}</Card.Description>
</Card.Header>
<Card.Content>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class="w-full justify-between sm:w-80"
{...props}
>
{l.lang}
<ChevronsUpDownIcon class="size-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_locale_search_locale_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_locale_no_locale_found()}</Command.Empty>
{#each list as loc (loc)}
<Command.Item
value={loc}
onSelect={() => {
open = false;
if (loc !== l.lang)
pick(() => setLocale({ lang: loc, language: l.language || undefined,machineId }));
}}
>
<CheckIcon
class={'mr-2 size-4 ' + (loc === l.lang ? 'opacity-100' : 'opacity-0')}
/>
{loc}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_language()}</Card.Title>
<Card.Description>{m.system_locale_language_desc()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
{#each langList as loc, i (loc + i)}
<Badge variant="secondary" class="gap-1 pe-1">
{loc}
<button
type="button"
class="hover:bg-muted ms-0.5 rounded-sm p-0.5"
aria-label="Remove"
disabled={saving}
onclick={() => (langList = langList.filter((_, j) => j !== i))}
>
<XIcon class="size-3" />
</button>
</Badge>
{:else}
<span class="text-muted-foreground text-sm">{m.system_locale_language_empty()}</span>
{/each}
<Popover.Root bind:open={langOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" size="sm" disabled={saving} {...props}>
<PlusIcon class="size-4" />
{m.system_locale_language_add()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_locale_search_locale_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_locale_no_locale_found()}</Command.Empty>
{#each list as loc (loc)}
<Command.Item
value={loc}
onSelect={() => {
langOpen = false;
if (!langList.includes(loc)) langList = [...langList, loc];
}}
>
<CheckIcon
class={'mr-2 size-4 ' +
(langList.includes(loc) ? 'opacity-100' : 'opacity-0')}
/>
{loc}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
<div>
<Button
variant="default"
disabled={saving || !languageDirty}
onclick={() => pick(handleSetLanguage)}
>
{m.system_locale_language_button()}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_keymap()}</Card.Title>
<Card.Description>{l.vc_keymap || '—'}</Card.Description>
</Card.Header>
<Card.Content>
{#if kms.reason && kms.keymaps.length === 0}
<p class="text-muted-foreground text-sm">{kms.reason}</p>
{:else}
<Popover.Root bind:open={kmOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={kmOpen}
class="w-full justify-between sm:w-80"
{...props}
>
{l.vc_keymap || '—'}
<ChevronsUpDownIcon class="size-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_locale_search_keymap_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_locale_no_keymap_found()}</Command.Empty>
{#each kms.keymaps as km (km)}
<Command.Item
value={km}
onSelect={() => {
kmOpen = false;
if (km !== l.vc_keymap) pick(() => setKeymap({keymap:km,machineId}));
}}
>
<CheckIcon
class={'mr-2 size-4 ' + (km === l.vc_keymap ? 'opacity-100' : 'opacity-0')}
/>
{km}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_x11()}</Card.Title>
<Card.Description>{l.x11_layout || '—'}</Card.Description>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_generate()}</Card.Title>
<Card.Description>{m.system_locale_generate_desc()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex flex-col sm:flex-row gap-3 max-w-md">
<Input
type="text"
placeholder={m.system_locale_generate_placeholder()}
bind:value={newLocale}
disabled={saving}
/>
<Button
variant="default"
disabled={saving || !localeValid}
onclick={() => pick(handleGenerate)}
>
{m.system_locale_generate_button()}
</Button>
</div>
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import PowerIcon from '@lucide/svelte/icons/power';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { page } from '$app/state';
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 { toast } from 'svelte-sonner';
let pending = $state<'off' | 'reboot' | null>(null);
let busy = $state(false);
const machineId = $derived(page.params.machineId!)
async function run(action: 'off' | 'reboot') {
busy = true;
try {
await (action === 'off' ? powerOff({machineId,when:''}) : reboot({machineId,when:''}));
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
} finally {
busy = false;
pending = null;
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl 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')}>
<RotateCcwIcon class="size-4" />
{m.system_power_reboot()}
</Button>
<Button variant="destructive" disabled={busy} onclick={() => (pending = 'off')}>
<PowerIcon class="size-4" />
{m.system_power_poweroff()}
</Button>
</Card.Content>
</Card.Root>
</div>
<AlertDialog.Root
open={pending !== null}
onOpenChange={(v) => {
if (!v) pending = null;
}}
>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{pending === 'off' ? m.system_power_confirm_poweroff_title() : m.system_power_confirm_reboot_title()}
</AlertDialog.Title>
<AlertDialog.Description>{m.system_power_confirm_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={busy}>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action
disabled={busy}
class={buttonVariants({ variant: 'destructive' })}
onclick={() => pending && run(pending)}
>
{pending === 'off' ? m.system_power_poweroff() : m.system_power_reboot()}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,512 @@
<script lang="ts">
import type { Pathname } from '$app/types';
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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
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 { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
createPamUser,
deletePamUser,
listPamUsers,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type PamUser = {
comment: string;
gid: number;
home: string;
shell: string;
system: boolean;
uid: number;
username: string;
};
type SortBy = 'shell' | 'uid' | 'username';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const users = $derived(listPamUsers(machineId));
const pageSize = new PersistedState<number>('pam.users.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('pam.users.sort', {
sortBy: 'uid',
sortDir: 'asc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
const filtersStore = new PersistedState('pam.users.filters', {
shellOnly: false,
showSystem: false
});
let filters = $state({ ...filtersStore.current });
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(
(filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0)
);
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
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 NOLOGIN = /(nologin|false)$/;
const filtered = $derived.by(() => {
const all = (users.current ?? []) as PamUser[];
const q = debouncedSearch.trim().toLowerCase();
const out = all.filter((u) => {
if (!filters.showSystem && u.system) return false;
if (filters.shellOnly && NOLOGIN.test(u.shell)) return false;
if (!q) return true;
return (
u.username.toLowerCase().includes(q) ||
u.comment.toLowerCase().includes(q) ||
String(u.uid).includes(q)
);
});
const dir = sortDir === 'asc' ? 1 : -1;
out.sort((a, b) => {
const av = a[sortBy] as number | string;
const bv = b[sortBy] as number | string;
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
// Dialogs
let createOpen = $state(false);
let createForm = $state({
comment: '',
create_home: true,
shell: '/bin/bash',
system: false,
username: ''
});
let pwOpen = $state(false);
let pwUser = $state<null | PamUser>(null);
let pwValue = $state('');
let deleteOpen = $state(false);
let deleting = $state<null | PamUser>(null);
let removeHome = $state(false);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function doCreate() {
try {
await createPamUser({
comment: createForm.comment || undefined,
create_home: createForm.create_home,
machineId,
shell: createForm.shell || undefined,
system: createForm.system,
username: createForm.username.trim()
});
toast.success(m.users_created());
createOpen = false;
createForm = { comment: '', create_home: true, shell: '/bin/bash', system: false, username: '' };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await deletePamUser({
machineId,
remove_home: removeHome, username: deleting.username });
toast.success(m.users_deleted());
deleteOpen = false;
deleting = null;
removeHome = false;
} catch (e) {
handleError(e);
}
}
async function doSetPassword() {
if (!pwUser || !pwValue) return;
try {
await setPamUserPassword({
machineId,
password: pwValue, username: pwUser.username });
toast.success(m.saved());
pwOpen = false;
pwUser = null;
pwValue = '';
} catch (e) {
handleError(e);
}
}
</script>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.users_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_shell_only()}</span>
<Checkbox
checked={filters.shellOnly}
onCheckedChange={(v) => {
filters.shellOnly = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pam-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pam-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.users_pam_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {users.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: 'UID' }, { key: 'shell', label: 'Shell' }] 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.users_col_comment()}</Table.Head>
<Table.Head>{m.users_col_home()}</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.loading && !users.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as u (u.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/${u.username}` as Pathname)}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
<Table.Cell>
{#if u.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</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={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = u;
removeHome = false;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onclick={() => (page += 1)}
>
{m.users_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_create_title()}</Dialog.Title>
<Dialog.Description>{m.users_pam_create_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="cu-username-{id}">{m.username()}</Label>
<Input
id="cu-username-{id}"
bind:value={createForm.username}
required
pattern="[a-z_][a-z0-9_-]*"
maxlength={32}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="cu-comment-{id}">{m.users_create_field_comment()}</Label>
<Input id="cu-comment-{id}" bind:value={createForm.comment} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="cu-shell-{id}">{m.users_create_field_shell()}</Label>
<Input id="cu-shell-{id}" bind:value={createForm.shell} />
</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.create_home}
onCheckedChange={(v) => (createForm.create_home = !!v)}
/>
{m.users_create_field_create_home()}
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
{m.users_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={pwOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title>
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSetPassword();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pw-{id}">{m.password()}</Label>
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.users_delete_description()}</AlertDialog.Description
>
</AlertDialog.Header>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={removeHome} />
{m.users_delete_field_remove_home()}
</label>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,265 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import KeyIcon from '@lucide/svelte/icons/key';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import {
deletePamUser,
getPamUser,
listPamGroups,
setPamUserGroups,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { toast } from 'svelte-sonner';
import { SvelteSet } from 'svelte/reactivity';
const id = $props.id();
const username = $derived(pageState.params.username!);
const machineId = $derived(pageState.params.machineId!);
const user = $derived(getPamUser({machineId,username,}));
const groups = $derived(listPamGroups(machineId));
const primary = $derived(
(groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1))
);
const supplementary = $derived(
(groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name)
);
let editing = $state(false);
const selected = new SvelteSet<string>()
$effect(() => {
if (!editing) {selected.clear(); selected.union(new Set(supplementary))}
});
const dirty = $derived.by(() => {
const cur = new Set(supplementary);
if (cur.size !== selected.size) return true;
for (const x of cur) if (!selected.has(x)) return true;
return false;
});
let pwOpen = $state(false);
let pwValue = $state('');
let deleteOpen = $state(false);
let removeHome = $state(false);
let saving = $state(false);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
function toggle(name: string, on: boolean) {
const next = new SvelteSet(selected);
if (on) next.add(name);
else next.delete(name);
selected.clear()
selected.union(next)
}
async function saveGroups() {
saving = true;
try {
await setPamUserGroups({ groups: [...selected], machineId, username });
toast.success(m.users_groups_updated());
editing = false;
} catch (e) {
handleError(e);
} finally {
saving = false;
}
}
async function doSetPassword() {
if (!pwValue) return;
try {
await setPamUserPassword({ machineId,password: pwValue, username });
toast.success(m.saved());
pwOpen = false;
pwValue = '';
} catch (e) {
handleError(e);
}
}
async function doDelete() {
try {
await deletePamUser({ machineId,remove_home: removeHome, username });
toast.success(m.users_deleted());
await goto(resolve(`/dashboard/${machineId}/users` as Pathname));
} catch (e) {
handleError(e);
}
}
</script>
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const u = await user}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{u.username}
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
>{/if}
</h1>
<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)}>
<KeyIcon class="size-4" />
{m.users_action_set_password()}
</Button>
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
<Trash2Icon class="size-4" />
{m.users_delete()}
</Button>
</div>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.users_details()}</Card.Title>
</Card.Header>
<Card.Content>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">UID</dt>
<dd class="font-mono">{u.uid}</dd>
<dt class="text-muted-foreground">{m.users_primary_gid()}</dt>
<dd class="font-mono">
{u.gid}{#if primary}
<span class="text-muted-foreground"> ({primary.name})</span>
{/if}
</dd>
<dt class="text-muted-foreground">{m.users_col_home()}</dt>
<dd class="font-mono">{u.home}</dd>
<dt class="text-muted-foreground">{m.users_create_field_shell()}</dt>
<dd class="font-mono">{u.shell}</dd>
</dl>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<div>
<Card.Title>{m.users_groups_title()}</Card.Title>
<Card.Description>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.users_pam_groups_description()}
</Card.Description>
</div>
{#if !editing}
<Button variant="outline" onclick={() => (editing = true)}>{m.edit()}</Button>
{:else}
<div class="flex items-center gap-2">
<Button
variant="outline"
onclick={() => {
editing = false;
selected.clear()
selected.union(new Set(supplementary))
}}>{m.cancel()}</Button
>
<Button onclick={saveGroups} disabled={saving || !dirty}>{m.save()}</Button>
</div>
{/if}
</Card.Header>
<Card.Content>
{#if !editing}
<div class="flex flex-wrap gap-1.5">
{#if primary}
<Badge variant="default" title="Primary group">{primary.name} {m.users_group_primary_badge()}</Badge>
{/if}
{#each supplementary as name (name)}
<Badge variant="outline">{name}</Badge>
{:else}
{#if !primary}
<span class="text-muted-foreground text-sm">{m.users_no_groups()}</span>
{/if}
{/each}
</div>
{:else}
<div class="max-h-80 overflow-auto rounded-md border">
<div class="grid grid-cols-2 gap-1 p-2 sm:grid-cols-3">
{#each (groups.current ?? []).filter((g) => g.gid !== u.gid) as g (g.name)}
<label class="flex items-center gap-2 rounded p-1.5 text-sm hover:bg-muted">
<Checkbox
checked={selected.has(g.name)}
onCheckedChange={(v) => toggle(g.name, !!v)}
/>
<span class="truncate" title={g.name}>{g.name}</span>
{#if g.system}
<span class="text-muted-foreground text-xs">{m.users_group_sys_badge()}</span>
{/if}
</label>
{/each}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
<Dialog.Root bind:open={pwOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_set_password_title({ username })}</Dialog.Title>
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSetPassword();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pw-{id}">{m.password()}</Label>
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_title({ username })}</AlertDialog.Title>
<AlertDialog.Description>{m.users_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={removeHome} />
{m.users_delete_field_remove_home()}
</label>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,400 @@
<script lang="ts">
import type { Pathname } from '$app/types';
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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import {page as pageState} from '$app/state'
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
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 { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
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 { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type PamGroup = {
gid: number;
members: null | string[];
name: string;
system: boolean;
};
type SortBy = 'gid' | 'members' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!)
const groups = $derived(listPamGroups(machineId));
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('pam.groups.sort', {
sortBy: 'gid',
sortDir: 'asc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
const filtersStore = new PersistedState('pam.groups.filters', { showSystem: false });
let filters = $state({ ...filtersStore.current });
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(filters.showSystem ? 1 : 0);
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
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 = (groups.current ?? []) as PamGroup[];
const q = debouncedSearch.trim().toLowerCase();
const out = all.filter((g) => {
if (!filters.showSystem && g.system) return false;
if (!q) return true;
return g.name.toLowerCase().includes(q) || String(g.gid).includes(q);
});
const dir = sortDir === 'asc' ? 1 : -1;
out.sort((a, b) => {
const av = sortBy === 'members' ? (a.members?.length ?? 0) : (a[sortBy] as number | string);
const bv = sortBy === 'members' ? (b.members?.length ?? 0) : (b[sortBy] as number | string);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
let createOpen = $state(false);
let createForm = $state({ gid: '', name: '', system: false });
let deleteOpen = $state(false);
let deleting = $state<null | PamGroup>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function doCreate() {
try {
await createPamGroup({
gid: createForm.gid ? Number(createForm.gid) : undefined,
machineId,
name: createForm.name.trim(),
system: createForm.system
});
toast.success(m.groups_created());
createOpen = false;
createForm = { gid: '', name: '', system: false };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await deletePamGroup({group:deleting.name, machineId});
toast.success(m.groups_deleted());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
</script>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.groups_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.groups_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {groups.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: 'GID' }, { key: 'members', label: 'Members' }] 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>Type</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if groups.loading && !groups.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.groups_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as g (g.name)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/groups/${g.name}` as Pathname)}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<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}`
: ''}
</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if g.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</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
variant="destructive"
onclick={() => {
deleting = g;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onclick={() => (page += 1)}
>
{m.users_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.groups_create_title()}</Dialog.Title>
<Dialog.Description>{m.groups_create_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="cg-name-{id}">{m.name()}</Label>
<Input
id="cg-name-{id}"
bind:value={createForm.name}
required
pattern="[a-z_][a-z0-9_-]*"
maxlength={32}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="cg-gid-{id}">GID (optional)</Label>
<Input id="cg-gid-{id}" type="number" min="0" bind:value={createForm.gid} />
</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
{m.groups_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: deleting?.name ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,244 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import PlusIcon from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import XIcon from '@lucide/svelte/icons/x';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import {
deletePamGroup,
listPamGroups,
listPamUsers,
setPamUserGroups
} from '$lib/remotes/pam-users.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const groupName = $derived(pageState.params.group!);
const groups = $derived(listPamGroups(machineId));
const users = $derived(listPamUsers(machineId));
const group = $derived((groups.current ?? []).find((g) => g.name === groupName));
const members = $derived(group?.members ?? []);
const primaryMembers = $derived(
(users.current ?? []).filter((u) => u.gid === group?.gid).map((u) => u.username)
);
const candidates = $derived(
(users.current ?? [])
.map((u) => u.username)
.filter((n) => !members.includes(n) && !primaryMembers.includes(n))
);
function supplementaryOf(username: string): string[] {
return (groups.current ?? [])
.filter((g) => g.members?.includes(username))
.map((g) => g.name);
}
let addOpen = $state(false);
let addQuery = $state('');
let busy = $state<null | string>(null);
let deleteOpen = $state(false);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function addMember(username: string) {
busy = username;
try {
const next = [...new Set([...supplementaryOf(username), groupName])];
await setPamUserGroups({ groups: next,machineId, username });
toast.success(m.groups_member_added({ username }));
addOpen = false;
addQuery = '';
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
async function removeMember(username: string) {
busy = username;
try {
const next = supplementaryOf(username).filter((g) => g !== groupName);
await setPamUserGroups({ groups: next,machineId, username });
toast.success(m.groups_member_removed({ username }));
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
async function doDelete() {
try {
await deletePamGroup({group:groupName,machineId,});
toast.success(m.groups_deleted());
await goto(resolve(`/dashboard/${machineId}/users/groups` as Pathname));
} catch (e) {
handleError(e);
}
}
const filteredCandidates = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
return q ? candidates.filter((n) => n.toLowerCase().includes(q)) : candidates;
});
</script>
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
{#if groups.loading && !groups.current}
<Card.Root>
<Card.Content class="text-muted-foreground py-6"></Card.Content>
</Card.Root>
{:else if !group}
<Card.Root>
<Card.Content class="text-destructive py-6">{m.groups_not_found({ name: groupName })}</Card.Content>
</Card.Root>
{:else}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{group.name}
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
</div>
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
<Trash2Icon class="size-4" />
{m.users_delete()}
</Button>
</div>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<div>
<Card.Title>{m.groups_members_title()}</Card.Title>
<Card.Description>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.groups_members_description({ gid: group.gid })}
</Card.Description>
</div>
<Button variant="outline" onclick={() => (addOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add_member()}
</Button>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div>
<Label class="mb-1.5 block text-xs">{m.groups_supplementary_label({ count: members.length })}</Label>
{#if members.length}
<div class="flex flex-wrap gap-1.5">
{#each members as name (name)}
<Badge variant="outline" class="gap-1 pe-1">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
class="hover:underline">{name}</a
>
<button
type="button"
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
disabled={busy === name}
onclick={() => removeMember(name)}
aria-label="Remove {name}"
>
<XIcon class="size-3" />
</button>
</Badge>
{/each}
</div>
{:else}
<span class="text-muted-foreground text-sm">{m.groups_no_supplementary_members()}</span>
{/if}
</div>
<div>
<Label class="mb-1.5 block text-xs">{m.groups_primary_label({ count: primaryMembers.length })}</Label>
{#if primaryMembers.length}
<div class="flex flex-wrap gap-1.5">
{#each primaryMembers as name (name)}
<Badge variant="default">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
class="hover:underline">{name}</a
>
</Badge>
{/each}
</div>
{:else}
<span class="text-muted-foreground text-sm">{m.groups_primary_empty()}</span>
{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
</div>
<Dialog.Root bind:open={addOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.groups_add_member_title({ name: groupName })}</Dialog.Title>
<Dialog.Description>{m.groups_add_member_description()}</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-2">
<Input
id="add-q-{id}"
placeholder={m.groups_add_member_search_placeholder()}
bind:value={addQuery}
autocomplete="off"
/>
<div class="max-h-72 overflow-auto rounded-md border">
{#if !filteredCandidates.length}
<div class="text-muted-foreground p-3 text-sm">{m.groups_add_member_no_results()}</div>
{:else}
{#each filteredCandidates as name (name)}
<button
type="button"
class="hover:bg-muted flex w-full items-center justify-between px-3 py-2 text-sm disabled:opacity-50"
disabled={busy === name}
onclick={() => addMember(name)}
>
<span>{name}</span>
<span class="text-muted-foreground text-xs">{m.groups_add_member_action()}</span>
</button>
{/each}
{/if}
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (addOpen = false)}>{m.cancel()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: groupName })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>