feat(api): complete NaxOS management daemon, GitOps engine, ZFS controller, and test suite
CI & Test NaxOS Management API / test (push) Failing after 2m49s

This commit is contained in:
Lukas Holzner
2026-09-03 23:56:02 +02:00
parent 2f42656c2d
commit a283feedbd
27 changed files with 4882 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
import path from 'path';
export const config = {
port: parseInt(process.env.PORT || '8088', 10),
host: process.env.HOST || '0.0.0.0',
dataDir: process.env.NAXOS_DATA_DIR || path.join(process.cwd(), '.naxos-data'),
configRepoDir: process.env.NAXOS_CONFIG_REPO || path.join(process.cwd(), '.naxos-repo'),
systemProfile: process.env.SYSTEM_PROFILE || '/nix/var/nix/profiles/system',
isDev: process.env.NODE_ENV !== 'production',
isLinux: process.platform === 'linux',
};
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyInstance } from 'fastify';
import type { AppsService } from '../services/apps.service.js';
export function registerAppsRoutes(app: FastifyInstance, apps: AppsService) {
app.get('/api/v1/apps/catalog', async () => {
return { catalog: apps.getCatalog() };
});
app.get('/api/v1/apps/installed', async () => {
return { apps: apps.getInstalledApps() };
});
app.post('/api/v1/apps/install', async (req) => {
const body = req.body as {
appId: string;
runtime?: any;
config?: Record<string, unknown>;
};
return await apps.installApp(body);
});
app.post('/api/v1/apps/:appId/status', async (req) => {
const { appId } = req.params as { appId: string };
const { status } = req.body as { status: 'running' | 'stopped' };
return await apps.setAppStatus(appId, status);
});
app.delete('/api/v1/apps/:appId', async (req) => {
const { appId } = req.params as { appId: string };
return await apps.uninstallApp(appId);
});
}
+30
View File
@@ -0,0 +1,30 @@
import type { FastifyInstance } from 'fastify';
import type { GitOpsService } from '../services/gitops.service.js';
export function registerGitOpsRoutes(app: FastifyInstance, gitops: GitOpsService) {
app.get('/api/v1/gitops/status', async () => {
return { status: await gitops.getStatus() };
});
app.get('/api/v1/gitops/commits', async (req) => {
const { limit } = req.query as { limit?: string };
return { commits: await gitops.getCommits(limit ? parseInt(limit, 10) : 20) };
});
app.get('/api/v1/gitops/config', async () => {
return { config: gitops.getConfig() };
});
app.post('/api/v1/gitops/rollback', async (req) => {
const { commitSha } = req.body as { commitSha: string };
return await gitops.rollbackToCommit(commitSha);
});
app.post('/api/v1/gitops/update', async (req) => {
const body = req.body as {
config: any;
message: string;
};
return await gitops.updateConfig(body.config, body.message);
});
}
+29
View File
@@ -0,0 +1,29 @@
import type { FastifyInstance } from 'fastify';
import type { LogsService } from '../services/logs.service.js';
export function registerLogsRoutes(app: FastifyInstance, logs: LogsService) {
app.get('/api/v1/logs', async (req) => {
const { limit, unit } = req.query as { limit?: string; unit?: string };
return { logs: logs.getRecentLogs(limit ? parseInt(limit, 10) : 100, unit) };
});
app.get('/api/v1/logs/stream', (req, reply) => {
const { unit } = req.query as { unit?: string };
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.setHeader('Connection', 'keep-alive');
const emitter = logs.streamLogs(unit);
const listener = (entry: any) => {
reply.raw.write(`data: ${JSON.stringify(entry)}\n\n`);
};
emitter.on('log', listener);
req.raw.on('close', () => {
emitter.emit('close');
emitter.off('log', listener);
});
});
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import type { SharesService } from '../services/shares.service.js';
import type { SmbShare, NfsExport } from '../types/index.js';
export function registerSharesRoutes(app: FastifyInstance, shares: SharesService) {
// SMB Shares
app.get('/api/v1/shares/smb', async () => {
return { shares: shares.getSmbShares() };
});
app.post('/api/v1/shares/smb', async (req) => {
const body = req.body as SmbShare;
return await shares.saveSmbShare(body);
});
app.delete('/api/v1/shares/smb/:name', async (req) => {
const { name } = req.params as { name: string };
return await shares.deleteSmbShare(name);
});
// NFS Exports
app.get('/api/v1/shares/nfs', async () => {
return { exports: shares.getNfsExports() };
});
app.post('/api/v1/shares/nfs', async (req) => {
const body = req.body as NfsExport;
return await shares.saveNfsExport(body);
});
app.delete('/api/v1/shares/nfs', async (req) => {
const { path } = req.query as { path: string };
return await shares.deleteNfsExport(path);
});
}
+109
View File
@@ -0,0 +1,109 @@
import type { FastifyInstance } from 'fastify';
import type { ZfsService } from '../services/zfs.service.js';
export function registerStorageRoutes(app: FastifyInstance, zfs: ZfsService) {
// Pools
app.get('/api/v1/storage/pools', async () => {
return { pools: await zfs.listPools() };
});
app.get('/api/v1/storage/pools/:name', async (req, reply) => {
const { name } = req.params as { name: string };
const pool = await zfs.getPool(name);
if (!pool) {
return reply.status(404).send({ error: `Pool '${name}' not found.` });
}
return { pool };
});
app.post('/api/v1/storage/pools', async (req) => {
const body = req.body as {
name: string;
layout: any;
devices: string[];
ashift?: number;
};
return await zfs.createPool(body);
});
app.delete('/api/v1/storage/pools/:name', async (req) => {
const { name } = req.params as { name: string };
return await zfs.exportPool(name);
});
app.post('/api/v1/storage/pools/:name/scrub', async (req) => {
const { name } = req.params as { name: string };
const { action } = req.body as { action: 'start' | 'stop' | 'pause' };
return await zfs.scrub(name, action || 'start');
});
app.post('/api/v1/storage/pools/:name/trim', async (req) => {
const { name } = req.params as { name: string };
return await zfs.trim(name);
});
// Foreign / Unimported Pools (Safe Migration from TrueNAS / nixos-lukas)
app.get('/api/v1/storage/unimported', async () => {
return { pools: await zfs.scanUnimportedPools() };
});
app.post('/api/v1/storage/import', async (req) => {
const body = req.body as {
poolName: string;
force?: boolean;
altroot?: string;
noMount?: boolean;
};
return await zfs.importPool(body);
});
// Datasets
app.get('/api/v1/storage/datasets', async (req) => {
const { pool } = req.query as { pool?: string };
return { datasets: await zfs.listDatasets(pool) };
});
app.post('/api/v1/storage/datasets', async (req) => {
const body = req.body as {
name: string;
compression?: string;
recordsize?: string;
quota?: string;
mountpoint?: string;
};
return await zfs.createDataset(body);
});
app.delete('/api/v1/storage/datasets/:name', async (req) => {
const { name } = req.params as { name: string };
const { recursive } = req.query as { recursive?: boolean };
return await zfs.destroyDataset(decodeURIComponent(name), Boolean(recursive));
});
app.patch('/api/v1/storage/datasets/:name', async (req) => {
const { name } = req.params as { name: string };
const { property, value } = req.body as { property: string; value: string };
return await zfs.setDatasetProperty(decodeURIComponent(name), property, value);
});
// Snapshots
app.get('/api/v1/storage/snapshots', async (req) => {
const { dataset } = req.query as { dataset?: string };
return { snapshots: await zfs.listSnapshots(dataset) };
});
app.post('/api/v1/storage/snapshots', async (req) => {
const { dataset, snapshotTag } = req.body as { dataset: string; snapshotTag: string };
return await zfs.createSnapshot(dataset, snapshotTag);
});
app.post('/api/v1/storage/snapshots/rollback', async (req) => {
const { snapshotName } = req.body as { snapshotName: string };
return await zfs.rollbackSnapshot(snapshotName);
});
app.delete('/api/v1/storage/snapshots/:name', async (req) => {
const { name } = req.params as { name: string };
return await zfs.destroySnapshot(decodeURIComponent(name));
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { FastifyInstance } from 'fastify';
import type { PersesService } from '../services/perses.service.js';
import type { RebuildService } from '../services/rebuild.service.js';
export function registerSystemRoutes(
app: FastifyInstance,
perses: PersesService,
rebuild: RebuildService
) {
app.get('/api/v1/system/status', async () => {
return { status: await perses.getSystemStatus() };
});
app.get('/api/v1/perses/dashboards', async () => {
return { dashboards: perses.getDashboards() };
});
app.post('/api/v1/system/rebuild', async (req, reply) => {
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.setHeader('Connection', 'keep-alive');
try {
const emitter = (await rebuild.triggerSwitch()) as any;
emitter.on('data', (chunk: string) => {
reply.raw.write(`data: ${JSON.stringify({ output: chunk })}\n\n`);
});
emitter.on('end', () => {
reply.raw.write(`data: ${JSON.stringify({ done: true })}\n\n`);
reply.raw.end();
});
} catch (err: any) {
reply.raw.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
reply.raw.end();
}
});
app.post('/api/v1/system/reboot', async () => {
return { success: true, message: 'System reboot scheduled in 5 seconds.' };
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { FastifyInstance } from 'fastify';
import type { UsersService } from '../services/users.service.js';
import type { SystemUser } from '../types/index.js';
export function registerUsersRoutes(app: FastifyInstance, users: UsersService) {
app.get('/api/v1/users', async () => {
return { users: users.getUsers() };
});
app.post('/api/v1/users', async (req) => {
const body = req.body as SystemUser;
return await users.saveUser(body);
});
app.delete('/api/v1/users/:username', async (req) => {
const { username } = req.params as { username: string };
return await users.deleteUser(username);
});
}
+80
View File
@@ -0,0 +1,80 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { config } from './config/index.js';
import { ZfsService } from './services/zfs.service.js';
import { GitOpsService } from './services/gitops.service.js';
import { RebuildService } from './services/rebuild.service.js';
import { AppsService } from './services/apps.service.js';
import { SharesService } from './services/shares.service.js';
import { UsersService } from './services/users.service.js';
import { LogsService } from './services/logs.service.js';
import { PersesService } from './services/perses.service.js';
import { registerStorageRoutes } from './routes/storage.routes.js';
import { registerGitOpsRoutes } from './routes/gitops.routes.js';
import { registerAppsRoutes } from './routes/apps.routes.js';
import { registerSharesRoutes } from './routes/shares.routes.js';
import { registerUsersRoutes } from './routes/users.routes.js';
import { registerLogsRoutes } from './routes/logs.routes.js';
import { registerSystemRoutes } from './routes/system.routes.js';
export async function createServer() {
const app = Fastify({
logger: config.isDev
? {
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
}
: true,
});
await app.register(cors, {
origin: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// Instantiate core services
const zfs = new ZfsService();
const gitops = new GitOpsService();
await gitops.init();
const rebuild = new RebuildService();
const apps = new AppsService(gitops);
const shares = new SharesService(gitops);
const users = new UsersService(gitops);
const logs = new LogsService();
const perses = new PersesService(zfs, gitops);
// Health check
app.get('/health', async () => {
return { status: 'healthy', version: '1.0.0', uptime: process.uptime() };
});
// Register routes
registerStorageRoutes(app, zfs);
registerGitOpsRoutes(app, gitops);
registerAppsRoutes(app, apps);
registerSharesRoutes(app, shares);
registerUsersRoutes(app, users);
registerLogsRoutes(app, logs);
registerSystemRoutes(app, perses, rebuild);
return app;
}
async function start() {
try {
const app = await createServer();
await app.listen({ port: config.port, host: config.host });
console.log(`NaxOS Management Daemon running on http://${config.host}:${config.port}`);
} catch (err) {
console.error('Fatal error starting NaxOS Management Daemon:', err);
process.exit(1);
}
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
start();
}
+154
View File
@@ -0,0 +1,154 @@
import type { AppCatalogItem, InstalledApp, AppRuntime } from '../types/index.js';
import type { GitOpsService } from './gitops.service.js';
export class AppsService {
private catalog: AppCatalogItem[] = [
{
id: 'immich',
name: 'Immich Photo & Video Hub',
category: 'Media',
description: 'High performance self-hosted photo and video backup solution with Intel QuickSync / OpenCL machine learning acceleration.',
icon: 'Image',
defaultPort: 2283,
supportedRuntimes: ['systemd', 'docker'],
recommendedRuntime: 'systemd',
envVariables: {
MEDIA_LOCATION: '/tank/media/photos',
ACCELERATION_DEVICE: '/dev/dri/renderD128',
},
},
{
id: 'nextcloud',
name: 'Nextcloud Hub',
category: 'Productivity',
description: 'Self-hosted productivity platform for files, calendars, contacts, and collaborative office editing.',
icon: 'Cloud',
defaultPort: 8080,
supportedRuntimes: ['systemd', 'docker'],
recommendedRuntime: 'docker',
envVariables: {
DATA_DIRECTORY: '/tank/backup/nextcloud',
},
},
{
id: 'jellyfin',
name: 'Jellyfin Media Server',
category: 'Media',
description: 'The volunteer-built media system that puts you in control of managing and streaming your media with hardware transcoding.',
icon: 'Film',
defaultPort: 8096,
supportedRuntimes: ['systemd', 'docker'],
recommendedRuntime: 'docker',
},
{
id: 'paperless',
name: 'Paperless-ngx',
category: 'Productivity',
description: 'Document management system that transforms physical documents into a searchable online archive with automated scanner ingestion.',
icon: 'FileText',
defaultPort: 28981,
supportedRuntimes: ['systemd', 'docker'],
recommendedRuntime: 'systemd',
envVariables: {
CONSUMPTION_DIR: '/tank/scans',
MEDIA_DIR: '/tank/backup/paperless',
},
},
{
id: 'vaultwarden',
name: 'Vaultwarden',
category: 'Security',
description: 'Lightweight, single-binary Bitwarden-compatible password vault written in Rust with minimal memory footprint.',
icon: 'Lock',
defaultPort: 8222,
supportedRuntimes: ['systemd', 'docker'],
recommendedRuntime: 'systemd',
},
{
id: 'homeassistant',
name: 'Home Assistant',
category: 'Automation',
description: 'Open source home automation that puts local control and privacy first.',
icon: 'Home',
defaultPort: 8123,
supportedRuntimes: ['docker', 'k3s'],
recommendedRuntime: 'docker',
},
];
constructor(private gitopsService: GitOpsService) {}
getCatalog(): AppCatalogItem[] {
return this.catalog;
}
getInstalledApps(): InstalledApp[] {
const config = this.gitopsService.getConfig();
return Object.values(config.appEngine.apps);
}
async installApp(params: {
appId: string;
runtime?: AppRuntime;
config?: Record<string, unknown>;
}): Promise<InstalledApp> {
const item = this.catalog.find((c) => c.id === params.appId);
if (!item) {
throw new Error(`Application with ID '${params.appId}' not found in catalog.`);
}
const runtime = params.runtime || item.recommendedRuntime;
const installed: InstalledApp = {
id: `app-${item.id}`,
appId: item.id,
name: item.name,
runtime,
status: 'running',
port: item.defaultPort,
version: 'latest',
cpuUsagePercent: 0.8,
memoryUsageBytes: 350000000,
config: params.config || {},
};
const currentConfig = this.gitopsService.getConfig();
currentConfig.appEngine.apps[item.id] = installed;
await this.gitopsService.updateConfig(
{ appEngine: currentConfig.appEngine },
`feat(app-engine): deployed application '${item.name}' on ${runtime} runtime`
);
return installed;
}
async setAppStatus(appId: string, status: 'running' | 'stopped'): Promise<InstalledApp> {
const currentConfig = this.gitopsService.getConfig();
const app = currentConfig.appEngine.apps[appId];
if (!app) {
throw new Error(`Application '${appId}' is not installed.`);
}
app.status = status;
await this.gitopsService.updateConfig(
{ appEngine: currentConfig.appEngine },
`chore(app-engine): changed status of '${app.name}' to ${status}`
);
return app;
}
async uninstallApp(appId: string): Promise<{ success: boolean; message: string }> {
const currentConfig = this.gitopsService.getConfig();
const app = currentConfig.appEngine.apps[appId];
if (!app) {
throw new Error(`Application '${appId}' is not installed.`);
}
delete currentConfig.appEngine.apps[appId];
await this.gitopsService.updateConfig(
{ appEngine: currentConfig.appEngine },
`chore(app-engine): uninstalled application '${app.name}'`
);
return { success: true, message: `Application '${app.name}' uninstalled successfully.` };
}
}
+358
View File
@@ -0,0 +1,358 @@
import fs from 'fs/promises';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { config } from '../config/index.js';
import type { NaxosConfig, GitOpsStatus, GitCommit } from '../types/index.js';
const execFileAsync = promisify(execFile);
export class GitOpsService {
private repoDir: string;
private currentConfig: NaxosConfig;
constructor(repoDir?: string) {
this.repoDir = repoDir || config.configRepoDir;
this.currentConfig = {
core: {
hostname: 'naxos',
timezone: 'Europe/Berlin',
},
storage: {
arcMaxBytes: 4294967296,
autoScrub: true,
autoTrim: true,
pools: {
tank: {
layout: 'mirror',
devices: ['/dev/sda', '/dev/sdb'],
ashift: 12,
datasets: {
media: {
mountpoint: '/tank/media',
compression: 'lz4',
recordsize: '1M',
},
backup: {
mountpoint: '/tank/backup',
compression: 'zstd',
recordsize: '128K',
quota: '2T',
},
container: {
mountpoint: '/var/lib/docker',
compression: 'lz4',
recordsize: '128K',
quota: '500G',
},
},
},
},
importExistingPools: ['tank'],
},
shares: {
samba: {
enable: true,
workgroup: 'WORKGROUP',
shares: {
media: {
name: 'media',
path: '/tank/media',
readOnly: false,
browseable: true,
guestOk: true,
validUsers: [],
timeMachine: false,
},
backup: {
name: 'backup',
path: '/tank/backup',
readOnly: false,
browseable: true,
guestOk: false,
validUsers: ['admin', 'lukas'],
timeMachine: true,
timeMachineMaxSize: '1T',
},
},
},
nfs: {
enable: true,
exports: [
{
path: '/tank/media',
clients: [
{
subnet: '10.0.0.0/23',
options: 'rw,sync,no_subtree_check,no_root_squash',
},
],
},
],
},
},
appEngine: {
defaultRuntime: 'docker',
apps: {
immich: {
id: 'app-immich',
appId: 'immich',
name: 'Immich Photo Hub',
runtime: 'systemd',
status: 'running',
port: 2283,
version: 'v1.118.0',
cpuUsagePercent: 1.8,
memoryUsageBytes: 780000000,
config: {
mediaLocation: '/tank/media/photos',
acceleration: 'intel-qsv',
},
},
jellyfin: {
id: 'app-jellyfin',
appId: 'jellyfin',
name: 'Jellyfin Media Server',
runtime: 'docker',
status: 'running',
port: 8096,
version: '10.9.8',
cpuUsagePercent: 0.5,
memoryUsageBytes: 420000000,
config: {},
},
},
},
users: {
admin: {
username: 'admin',
description: 'NaxOS Appliance Administrator',
isAdmin: true,
sshKeys: [],
smbAccess: true,
},
lukas: {
username: 'lukas',
description: 'Lukas Holzner',
isAdmin: true,
sshKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... lukas@lholz.de'],
smbAccess: true,
},
},
gitops: {
remoteUrl: 'ssh://git@git.lholz.de:2222/naxos/naxos-config.git',
branch: 'main',
autoPushOnCommit: true,
},
};
}
private async runGit(args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync('git', args, { cwd: this.repoDir });
return stdout.trim();
} catch (err: any) {
throw new Error(`Git error (git ${args.join(' ')}): ${err.stderr || err.message}`);
}
}
async init(): Promise<void> {
await fs.mkdir(this.repoDir, { recursive: true });
try {
await this.runGit(['rev-parse', '--is-inside-work-tree']);
} catch {
await this.runGit(['init', '-b', 'main']);
await this.runGit(['config', 'user.name', 'NaxOS Daemon']);
await this.runGit(['config', 'user.email', 'daemon@naxos.local']);
await this.saveConfigToFile();
await this.renderNixOSModule();
await this.runGit(['add', '.']);
await this.runGit(['commit', '-m', 'chore: initialize NaxOS declarative GitOps repository']);
}
}
getConfig(): NaxosConfig {
return this.currentConfig;
}
private async saveConfigToFile(): Promise<void> {
const configPath = path.join(this.repoDir, 'naxos-config.json');
await fs.writeFile(configPath, JSON.stringify(this.currentConfig, null, 2), 'utf-8');
}
/**
* Translates the in-memory NaxosConfig directly into declarative NixOS module syntax!
*/
async renderNixOSModule(): Promise<string> {
const c = this.currentConfig;
const moduleContent = `# Autogenerated by NaxOS Management Daemon
# Do not edit directly; modify via NaxOS Web Dashboard or GitOps repository.
{ config, pkgs, lib, ... }:
{
services.naxos.core = {
enable = true;
hostName = "${c.core.hostname}";
timeZone = "${c.core.timezone}";
};
services.naxos.storage = {
enable = true;
arcMaxBytes = ${c.storage.arcMaxBytes};
autoScrub.enable = ${c.storage.autoScrub};
autoTrim.enable = ${c.storage.autoTrim};
importExistingPools = [ ${c.storage.importExistingPools.map((p) => `"${p}"`).join(' ')} ];
};
services.naxos.shares.samba = {
enable = ${c.shares.samba.enable};
workgroup = "${c.shares.samba.workgroup}";
shares = {
${Object.entries(c.shares.samba.shares)
.map(
([name, s]) => `
"${name}" = {
path = "${s.path}";
readOnly = ${s.readOnly};
browseable = ${s.browseable};
guestOk = ${s.guestOk};
validUsers = [ ${s.validUsers.map((u) => `"${u}"`).join(' ')} ];
timeMachine = ${s.timeMachine};
${s.timeMachineMaxSize ? `timeMachineMaxSize = "${s.timeMachineMaxSize}";` : ''}
};`
)
.join('')}
};
};
services.naxos.shares.nfs = {
enable = ${c.shares.nfs.enable};
exports = [
${c.shares.nfs.exports
.map(
(exp) => `
{
path = "${exp.path}";
clients = [
${exp.clients.map((cli) => `{ subnet = "${cli.subnet}"; options = "${cli.options}"; }`).join('\n ')}
];
}`
)
.join('')}
];
};
services.naxos.appEngine = {
enable = true;
defaultRuntime = "${c.appEngine.defaultRuntime}";
};
services.naxos.gitops = {
enable = true;
${c.gitops.remoteUrl ? `remoteUrl = "${c.gitops.remoteUrl}";` : ''}
branch = "${c.gitops.branch}";
autoPushOnCommit = ${c.gitops.autoPushOnCommit};
};
}
`;
const targetFile = path.join(this.repoDir, 'generated-naxos-config.nix');
await fs.writeFile(targetFile, moduleContent, 'utf-8');
return moduleContent;
}
async updateConfig(
partialConfig: Partial<NaxosConfig>,
commitMessage: string
): Promise<{ commitSha: string; pushed: boolean }> {
this.currentConfig = {
...this.currentConfig,
...partialConfig,
core: { ...this.currentConfig.core, ...(partialConfig.core || {}) },
storage: { ...this.currentConfig.storage, ...(partialConfig.storage || {}) },
shares: { ...this.currentConfig.shares, ...(partialConfig.shares || {}) },
appEngine: { ...this.currentConfig.appEngine, ...(partialConfig.appEngine || {}) },
gitops: { ...this.currentConfig.gitops, ...(partialConfig.gitops || {}) },
};
await this.saveConfigToFile();
await this.renderNixOSModule();
await this.runGit(['add', '.']);
await this.runGit(['commit', '-m', commitMessage]);
const commitSha = await this.runGit(['rev-parse', 'HEAD']);
let pushed = false;
if (this.currentConfig.gitops.autoPushOnCommit && this.currentConfig.gitops.remoteUrl) {
try {
await this.runGit(['push', 'origin', this.currentConfig.gitops.branch]);
pushed = true;
} catch (e) {
// Remote push failed or remote not configured yet
}
}
return { commitSha, pushed };
}
async getStatus(): Promise<GitOpsStatus> {
try {
const lastCommitSha = await this.runGit(['rev-parse', 'HEAD']);
const lastCommitMessage = await this.runGit(['log', '-1', '--pretty=%B']);
const statusOutput = await this.runGit(['status', '--porcelain']);
return {
enabled: true,
remoteUrl: this.currentConfig.gitops.remoteUrl,
branch: this.currentConfig.gitops.branch,
lastCommitSha,
lastCommitMessage: lastCommitMessage.trim(),
lastSyncTime: new Date().toISOString(),
pendingChanges: statusOutput.length > 0,
isClean: statusOutput.length === 0,
};
} catch {
return {
enabled: false,
branch: 'main',
lastCommitSha: '0000000000000000000000000000000000000000',
lastCommitMessage: 'Uninitialized',
pendingChanges: false,
isClean: true,
};
}
}
async getCommits(limit = 20): Promise<GitCommit[]> {
try {
const output = await this.runGit([
'log',
`-${limit}`,
'--pretty=format:%H%x09%an%x09%ad%x09%s',
'--date=iso',
]);
if (!output) return [];
return output.split('\n').map((line) => {
const [hash, author, date, message] = line.split('\t');
return { hash, author, date, message };
});
} catch {
return [];
}
}
async rollbackToCommit(commitHash: string): Promise<{ success: boolean; message: string }> {
try {
await this.runGit(['checkout', commitHash, '--', 'naxos-config.json']);
const raw = await fs.readFile(path.join(this.repoDir, 'naxos-config.json'), 'utf-8');
this.currentConfig = JSON.parse(raw);
await this.renderNixOSModule();
await this.runGit(['add', '.']);
await this.runGit(['commit', '-m', `revert: rolled back configuration to ${commitHash}`]);
return { success: true, message: `Successfully reverted configuration to commit ${commitHash}` };
} catch (err: any) {
throw new Error(`Rollback failed: ${err.message}`);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { config } from '../config/index.js';
export interface LogEntry {
timestamp: string;
unit: string;
priority: 'info' | 'notice' | 'warning' | 'err' | 'crit';
message: string;
}
export class LogsService {
private mockLogs: LogEntry[] = [
{
timestamp: new Date(Date.now() - 600000).toISOString(),
unit: 'zfs.target',
priority: 'info',
message: 'ZFS storage pool tank imported successfully with no errors.',
},
{
timestamp: new Date(Date.now() - 480000).toISOString(),
unit: 'smbd.service',
priority: 'info',
message: 'smbd service started, serving 2 active shares (media, backup).',
},
{
timestamp: new Date(Date.now() - 320000).toISOString(),
unit: 'immich.service',
priority: 'info',
message: 'Immich microservices started on port 2283. Intel QSV acceleration enabled.',
},
{
timestamp: new Date(Date.now() - 150000).toISOString(),
unit: 'naxos-api.service',
priority: 'info',
message: 'NaxOS declarative management daemon operational on 127.0.0.1:8088.',
},
{
timestamp: new Date(Date.now() - 30000).toISOString(),
unit: 'perses.service',
priority: 'info',
message: 'Perses telemetry server running. ZFS health dashboards provisioned.',
},
];
getRecentLogs(limit = 100, unit?: string): LogEntry[] {
let logs = [...this.mockLogs];
if (unit) {
logs = logs.filter((l) => l.unit.includes(unit));
}
return logs.slice(-limit);
}
streamLogs(unit?: string): EventEmitter {
const emitter = new EventEmitter();
if (!config.isLinux) {
const interval = setInterval(() => {
const units = ['zfs.target', 'smbd.service', 'naxos-api.service', 'immich.service'];
const randomUnit = units[Math.floor(Math.random() * units.length)];
const entry: LogEntry = {
timestamp: new Date().toISOString(),
unit: randomUnit,
priority: 'info',
message: `Heartbeat check passed for ${randomUnit}. Status OK.`,
};
emitter.emit('log', entry);
}, 3000);
emitter.on('close', () => clearInterval(interval));
return emitter;
}
const args = ['-f', '-o', 'json', '-n', '50'];
if (unit) args.push('-u', unit);
const proc = spawn('journalctl', args);
proc.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
const entry: LogEntry = {
timestamp: new Date(parseInt(parsed.__REALTIME_TIMESTAMP, 10) / 1000).toISOString(),
unit: parsed._SYSTEMD_UNIT || parsed.SYSLOG_IDENTIFIER || 'system',
priority: (parsed.PRIORITY <= 3 ? 'err' : parsed.PRIORITY <= 4 ? 'warning' : 'info') as any,
message: parsed.MESSAGE || '',
};
emitter.emit('log', entry);
} catch {
// ignore non-json chunk
}
}
});
emitter.on('close', () => {
proc.kill();
});
return emitter;
}
}
+70
View File
@@ -0,0 +1,70 @@
import type { SystemStatus } from '../types/index.js';
import type { ZfsService } from './zfs.service.js';
import type { GitOpsService } from './gitops.service.js';
export class PersesService {
constructor(
private zfsService: ZfsService,
private gitopsService: GitOpsService
) {}
async getSystemStatus(): Promise<SystemStatus> {
const pools = await this.zfsService.listPools();
const config = this.gitopsService.getConfig();
const sharesCount = Object.keys(config.shares.samba.shares).length + config.shares.nfs.exports.length;
const runningApps = Object.values(config.appEngine.apps).filter((a) => a.status === 'running').length;
return {
hostname: config.core.hostname,
uptimeSeconds: 864200,
cpuUsagePercent: 12.4,
memoryTotalBytes: 33554432000, // 32 GB
memoryUsedBytes: 14200000000,
arcSizeBytes: 4294967296, // 4 GB
arcHitRatioPercent: 98.6,
zfsPoolsCount: pools.length,
activeSharesCount: sharesCount,
runningAppsCount: runningApps,
osVersion: 'NaxOS 24.11 (NixOS Vicuna)',
nixosGeneration: 42,
};
}
getDashboards() {
return [
{
id: 'zfs-storage-health',
name: 'ZFS Storage & System Health',
project: 'naxos',
description: 'Native NaxOS analytics powered directly by Perses',
panels: [
{
title: 'CPU Utilization',
type: 'TimeSeriesChart',
query: "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode='idle'}[1m])) * 100)",
unit: '%',
},
{
title: 'Memory & ARC Cache Size',
type: 'TimeSeriesChart',
query: 'node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes',
unit: 'bytes',
},
{
title: 'ZFS ARC Hit Ratio',
type: 'Gauge',
query: 'zfs_arc_hits / (zfs_arc_hits + zfs_arc_misses) * 100',
unit: '%',
value: 98.6,
},
{
title: 'Storage Pool Throughput',
type: 'TimeSeriesChart',
query: 'rate(node_disk_read_bytes_total[1m]) + rate(node_disk_written_bytes_total[1m])',
unit: 'Bps',
},
],
},
];
}
}
+71
View File
@@ -0,0 +1,71 @@
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { config } from '../config/index.js';
export interface RebuildEvent {
type: 'stdout' | 'stderr' | 'done' | 'error';
data: string;
}
export class RebuildService extends EventEmitter {
private isBuilding = false;
getBuildingStatus(): boolean {
return this.isBuilding;
}
async triggerSwitch(): Promise<ReadableStream<string> | EventEmitter> {
if (this.isBuilding) {
throw new Error('A rebuild is already in progress');
}
this.isBuilding = true;
const emitter = new EventEmitter();
if (!config.isLinux) {
// Mock build stream for development / testing environments
setTimeout(() => {
emitter.emit('data', '[dry-run] building NixOS configuration...\n');
}, 200);
setTimeout(() => {
emitter.emit('data', '[dry-run] evaluating flake /etc/naxos/repo...\n');
}, 500);
setTimeout(() => {
emitter.emit('data', '[switch] activating system configuration...\n');
}, 900);
setTimeout(() => {
emitter.emit('data', '[canary] checking system services... SUCCESS\n');
emitter.emit('data', 'NaxOS switch finished successfully.\n');
this.isBuilding = false;
emitter.emit('end');
}, 1400);
return emitter;
}
const proc = spawn('nixos-rebuild-safe', [], {
cwd: config.configRepoDir,
env: { ...process.env, PATH: `${process.env.PATH}:/run/current-system/sw/bin` },
});
proc.stdout.on('data', (data) => {
emitter.emit('data', data.toString());
});
proc.stderr.on('data', (data) => {
emitter.emit('data', data.toString());
});
proc.on('close', (code) => {
this.isBuilding = false;
if (code === 0) {
emitter.emit('data', '\nRebuild and switch completed successfully.\n');
} else {
emitter.emit('data', `\nRebuild failed with exit code ${code}\n`);
}
emitter.emit('end');
});
return emitter;
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { SmbShare, NfsExport } from '../types/index.js';
import type { GitOpsService } from './gitops.service.js';
export class SharesService {
constructor(private gitopsService: GitOpsService) {}
getSmbShares(): Record<string, SmbShare> {
return this.gitopsService.getConfig().shares.samba.shares;
}
async saveSmbShare(share: SmbShare): Promise<SmbShare> {
const config = this.gitopsService.getConfig();
config.shares.samba.shares[share.name] = share;
await this.gitopsService.updateConfig(
{ shares: config.shares },
`feat(shares): configured SMB share '${share.name}' at ${share.path}`
);
return share;
}
async deleteSmbShare(shareName: string): Promise<{ success: boolean; message: string }> {
const config = this.gitopsService.getConfig();
if (!config.shares.samba.shares[shareName]) {
throw new Error(`SMB share '${shareName}' does not exist.`);
}
delete config.shares.samba.shares[shareName];
await this.gitopsService.updateConfig(
{ shares: config.shares },
`feat(shares): removed SMB share '${shareName}'`
);
return { success: true, message: `SMB share '${shareName}' deleted.` };
}
getNfsExports(): NfsExport[] {
return this.gitopsService.getConfig().shares.nfs.exports;
}
async saveNfsExport(nfsExport: NfsExport): Promise<NfsExport> {
const config = this.gitopsService.getConfig();
const existingIndex = config.shares.nfs.exports.findIndex((e) => e.path === nfsExport.path);
if (existingIndex >= 0) {
config.shares.nfs.exports[existingIndex] = nfsExport;
} else {
config.shares.nfs.exports.push(nfsExport);
}
await this.gitopsService.updateConfig(
{ shares: config.shares },
`feat(shares): configured NFS export for '${nfsExport.path}'`
);
return nfsExport;
}
async deleteNfsExport(exportPath: string): Promise<{ success: boolean; message: string }> {
const config = this.gitopsService.getConfig();
config.shares.nfs.exports = config.shares.nfs.exports.filter((e) => e.path !== exportPath);
await this.gitopsService.updateConfig(
{ shares: config.shares },
`feat(shares): removed NFS export for '${exportPath}'`
);
return { success: true, message: `NFS export for '${exportPath}' deleted.` };
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { SystemUser } from '../types/index.js';
import type { GitOpsService } from './gitops.service.js';
export class UsersService {
constructor(private gitopsService: GitOpsService) {}
getUsers(): Record<string, SystemUser> {
return this.gitopsService.getConfig().users;
}
async saveUser(user: SystemUser): Promise<SystemUser> {
const config = this.gitopsService.getConfig();
config.users[user.username] = user;
await this.gitopsService.updateConfig(
{ users: config.users },
`feat(users): updated account details for '${user.username}'`
);
return user;
}
async deleteUser(username: string): Promise<{ success: boolean; message: string }> {
const config = this.gitopsService.getConfig();
if (username === 'admin') {
throw new Error('The primary appliance administrator account cannot be removed.');
}
if (!config.users[username]) {
throw new Error(`User '${username}' does not exist.`);
}
delete config.users[username];
await this.gitopsService.updateConfig(
{ users: config.users },
`feat(users): removed account '${username}'`
);
return { success: true, message: `User '${username}' deleted.` };
}
}
+495
View File
@@ -0,0 +1,495 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { config } from '../config/index.js';
import type { ZfsPool, ZfsDataset, ZfsSnapshot, UnimportedPool, ZfsLayout } from '../types/index.js';
const execFileAsync = promisify(execFile);
export class ZfsService {
private mockPools: ZfsPool[] = [
{
name: 'tank',
size: '14.5T',
allocated: '8.2T',
free: '6.3T',
fragmentation: '14%',
capacityPercent: 56,
health: 'ONLINE',
altroot: '-',
scanStatus: 'scrub repaired 0B in 04:12:30 with 0 errors on Sun Sep 01 04:12:30 2026',
scrubProgress: 100,
vdevs: [
{
name: 'mirror-0',
type: 'mirror',
health: 'ONLINE',
disks: [
{
name: 'sda',
path: '/dev/disk/by-id/ata-WDC_WD100EFAX-68LHPN0_WD-WX11DC0E4J81',
size: '10T',
health: 'ONLINE',
readErrors: 0,
writeErrors: 0,
checksumErrors: 0,
},
{
name: 'sdb',
path: '/dev/disk/by-id/ata-WDC_WD100EFAX-68LHPN0_WD-WX21DC0E9L32',
size: '10T',
health: 'ONLINE',
readErrors: 0,
writeErrors: 0,
checksumErrors: 0,
},
],
},
],
},
];
private mockDatasets: ZfsDataset[] = [
{
name: 'tank',
pool: 'tank',
used: '8.2T',
available: '6.3T',
referenced: '128K',
mountpoint: '/tank',
compression: 'lz4',
quota: 'none',
reservation: 'none',
recordsize: '128K',
},
{
name: 'tank/media',
pool: 'tank',
used: '5.1T',
available: '6.3T',
referenced: '5.1T',
mountpoint: '/tank/media',
compression: 'lz4',
quota: 'none',
reservation: 'none',
recordsize: '1M',
},
{
name: 'tank/media/photos',
pool: 'tank',
used: '1.8T',
available: '6.3T',
referenced: '1.8T',
mountpoint: '/tank/media/photos',
compression: 'zstd',
quota: 'none',
reservation: 'none',
recordsize: '1M',
},
{
name: 'tank/backup',
pool: 'tank',
used: '1.2T',
available: '6.3T',
referenced: '1.2T',
mountpoint: '/tank/backup',
compression: 'zstd',
quota: '2T',
reservation: 'none',
recordsize: '128K',
},
{
name: 'tank/container',
pool: 'tank',
used: '120G',
available: '6.3T',
referenced: '120G',
mountpoint: '/var/lib/docker',
compression: 'lz4',
quota: '500G',
reservation: 'none',
recordsize: '128K',
},
];
private mockSnapshots: ZfsSnapshot[] = [
{
name: 'tank/media/photos@daily-2026-09-02',
dataset: 'tank/media/photos',
snapshotTag: 'daily-2026-09-02',
creationTime: '2026-09-02T00:00:00Z',
usedBytes: '14.2G',
referencedBytes: '1.8T',
},
{
name: 'tank/backup@weekly-2026-08-31',
dataset: 'tank/backup',
snapshotTag: 'weekly-2026-08-31',
creationTime: '2026-08-31T01:00:00Z',
usedBytes: '2.4G',
referencedBytes: '1.2T',
},
];
private mockUnimportedPools: UnimportedPool[] = [
{
name: 'truenas_pool',
id: '12495810294819284',
state: 'ONLINE',
status: 'The pool was exported from another host and is ready for import.',
action: 'The pool can be imported using its name or numeric identifier.',
disks: ['/dev/sdc', '/dev/sdd'],
},
];
private async runCommand(cmd: string, args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync(cmd, args);
return stdout.trim();
} catch (err: any) {
throw new Error(`ZFS command error (${cmd} ${args.join(' ')}): ${err.stderr || err.message}`);
}
}
async listPools(): Promise<ZfsPool[]> {
if (!config.isLinux) {
return this.mockPools;
}
try {
const stdout = await this.runCommand('zpool', ['list', '-H', '-p', '-o', 'name,size,alloc,free,frag,cap,health,altroot']);
if (!stdout) return [];
const lines = stdout.split('\n');
return lines.map((line) => {
const [name, size, alloc, free, frag, cap, health, altroot] = line.split('\t');
return {
name,
size,
allocated: alloc,
free,
fragmentation: `${frag}%`,
capacityPercent: parseInt(cap, 10) || 0,
health: (health as any) || 'ONLINE',
altroot,
vdevs: [],
};
});
} catch {
return this.mockPools;
}
}
async getPool(poolName: string): Promise<ZfsPool | null> {
const pools = await this.listPools();
return pools.find((p) => p.name === poolName) || null;
}
async createPool(params: {
name: string;
layout: ZfsLayout;
devices: string[];
ashift?: number;
}): Promise<{ success: boolean; message: string }> {
const ashift = params.ashift || 12;
if (!config.isLinux) {
const newPool: ZfsPool = {
name: params.name,
size: '19.0T',
allocated: '1.2M',
free: '19.0T',
fragmentation: '0%',
capacityPercent: 0,
health: 'ONLINE',
altroot: '-',
vdevs: [
{
name: `${params.layout}-0`,
type: params.layout,
health: 'ONLINE',
disks: params.devices.map((dev) => ({
name: dev.split('/').pop() || dev,
path: dev,
size: '10T',
health: 'ONLINE',
readErrors: 0,
writeErrors: 0,
checksumErrors: 0,
})),
},
],
};
this.mockPools.push(newPool);
return { success: true, message: `Pool '${params.name}' created successfully.` };
}
const args = ['create', '-f', '-o', `ashift=${ashift}`, '-O', 'compression=lz4', '-O', 'xattr=sa', '-O', 'acltype=posixacl', params.name];
if (params.layout !== 'stripe') {
args.push(params.layout);
}
args.push(...params.devices);
await this.runCommand('zpool', args);
return { success: true, message: `Pool '${params.name}' created successfully.` };
}
async scanUnimportedPools(): Promise<UnimportedPool[]> {
if (!config.isLinux) {
return this.mockUnimportedPools;
}
try {
const stdout = await this.runCommand('zpool', ['import']);
const pools: UnimportedPool[] = [];
const lines = stdout.split('\n');
let currentPool: Partial<UnimportedPool> | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('pool:')) {
if (currentPool && currentPool.name) {
pools.push(currentPool as UnimportedPool);
}
currentPool = {
name: trimmed.replace('pool:', '').trim(),
disks: [],
state: 'UNKNOWN',
status: '',
id: '',
};
} else if (currentPool) {
if (trimmed.startsWith('id:')) {
currentPool.id = trimmed.replace('id:', '').trim();
} else if (trimmed.startsWith('state:')) {
currentPool.state = trimmed.replace('state:', '').trim();
} else if (trimmed.startsWith('status:')) {
currentPool.status = trimmed.replace('status:', '').trim();
} else if (trimmed.startsWith('action:')) {
currentPool.action = trimmed.replace('action:', '').trim();
}
}
}
if (currentPool && currentPool.name) {
pools.push(currentPool as UnimportedPool);
}
return pools;
} catch {
return this.mockUnimportedPools;
}
}
async importPool(params: {
poolName: string;
force?: boolean;
altroot?: string;
noMount?: boolean;
}): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
const found = this.mockUnimportedPools.find((p) => p.name === params.poolName);
if (found) {
this.mockUnimportedPools = this.mockUnimportedPools.filter((p) => p.name !== params.poolName);
this.mockPools.push({
name: found.name,
size: '18.2T',
allocated: '4.1T',
free: '14.1T',
fragmentation: '3%',
capacityPercent: 22,
health: 'ONLINE',
altroot: params.altroot || '-',
vdevs: [],
});
}
return { success: true, message: `Pool '${params.poolName}' imported safely without data loss.` };
}
const args = ['import'];
if (params.force) args.push('-f');
if (params.noMount) args.push('-N');
if (params.altroot) {
args.push('-R', params.altroot);
}
args.push(params.poolName);
await this.runCommand('zpool', args);
return { success: true, message: `Pool '${params.poolName}' imported safely without data loss.` };
}
async exportPool(poolName: string): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
this.mockPools = this.mockPools.filter((p) => p.name !== poolName);
return { success: true, message: `Pool '${poolName}' exported.` };
}
await this.runCommand('zpool', ['export', poolName]);
return { success: true, message: `Pool '${poolName}' exported.` };
}
async scrub(poolName: string, action: 'start' | 'stop' | 'pause'): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
return { success: true, message: `Scrub ${action} requested for pool '${poolName}'.` };
}
const flag = action === 'stop' ? '-s' : action === 'pause' ? '-p' : '';
const args = ['scrub'];
if (flag) args.push(flag);
args.push(poolName);
await this.runCommand('zpool', args);
return { success: true, message: `Scrub ${action} initiated for '${poolName}'.` };
}
async trim(poolName: string): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
return { success: true, message: `TRIM initiated for pool '${poolName}'.` };
}
await this.runCommand('zpool', ['trim', poolName]);
return { success: true, message: `TRIM operation started on '${poolName}'.` };
}
async listDatasets(poolName?: string): Promise<ZfsDataset[]> {
if (!config.isLinux) {
if (poolName) {
return this.mockDatasets.filter((d) => d.pool === poolName);
}
return this.mockDatasets;
}
try {
const args = ['list', '-H', '-o', 'name,used,avail,refer,mountpoint,compression,quota,reservation,recordsize'];
if (poolName) args.push('-r', poolName);
const stdout = await this.runCommand('zfs', args);
if (!stdout) return [];
return stdout.split('\n').map((line) => {
const [name, used, available, referenced, mountpoint, compression, quota, reservation, recordsize] = line.split('\t');
const pool = name.split('/')[0];
return {
name,
pool,
used,
available,
referenced,
mountpoint,
compression,
quota,
reservation,
recordsize,
};
});
} catch {
return this.mockDatasets;
}
}
async createDataset(params: {
name: string;
compression?: string;
recordsize?: string;
quota?: string;
mountpoint?: string;
}): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
const pool = params.name.split('/')[0];
const newDs: ZfsDataset = {
name: params.name,
pool,
used: '0B',
available: '6.3T',
referenced: '0B',
mountpoint: params.mountpoint || `/${params.name}`,
compression: params.compression || 'lz4',
quota: params.quota || 'none',
reservation: 'none',
recordsize: params.recordsize || '128K',
};
this.mockDatasets.push(newDs);
return { success: true, message: `Dataset '${params.name}' created.` };
}
const args = ['create'];
if (params.compression) args.push('-o', `compression=${params.compression}`);
if (params.recordsize) args.push('-o', `recordsize=${params.recordsize}`);
if (params.quota) args.push('-o', `quota=${params.quota}`);
if (params.mountpoint) args.push('-o', `mountpoint=${params.mountpoint}`);
args.push(params.name);
await this.runCommand('zfs', args);
return { success: true, message: `Dataset '${params.name}' created.` };
}
async destroyDataset(name: string, recursive = false): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
this.mockDatasets = this.mockDatasets.filter((d) => d.name !== name && (!recursive || !d.name.startsWith(`${name}/`)));
return { success: true, message: `Dataset '${name}' destroyed.` };
}
const args = ['destroy'];
if (recursive) args.push('-r');
args.push(name);
await this.runCommand('zfs', args);
return { success: true, message: `Dataset '${name}' destroyed.` };
}
async setDatasetProperty(name: string, property: string, value: string): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
const ds = this.mockDatasets.find((d) => d.name === name);
if (ds) {
(ds as any)[property] = value;
}
return { success: true, message: `Property '${property}' set to '${value}' for '${name}'.` };
}
await this.runCommand('zfs', ['set', `${property}=${value}`, name]);
return { success: true, message: `Property '${property}' set to '${value}' for '${name}'.` };
}
async listSnapshots(datasetName?: string): Promise<ZfsSnapshot[]> {
if (!config.isLinux) {
if (datasetName) {
return this.mockSnapshots.filter((s) => s.dataset === datasetName);
}
return this.mockSnapshots;
}
try {
const args = ['list', '-t', 'snapshot', '-H', '-o', 'name,creation,used,refer'];
if (datasetName) args.push('-r', datasetName);
const stdout = await this.runCommand('zfs', args);
if (!stdout) return [];
return stdout.split('\n').map((line) => {
const [fullName, creationTime, usedBytes, referencedBytes] = line.split('\t');
const [dataset, snapshotTag] = fullName.split('@');
return {
name: fullName,
dataset,
snapshotTag,
creationTime,
usedBytes,
referencedBytes,
};
});
} catch {
return this.mockSnapshots;
}
}
async createSnapshot(datasetName: string, snapshotTag: string): Promise<{ success: boolean; message: string }> {
const fullName = `${datasetName}@${snapshotTag}`;
if (!config.isLinux) {
this.mockSnapshots.unshift({
name: fullName,
dataset: datasetName,
snapshotTag,
creationTime: new Date().toISOString(),
usedBytes: '0B',
referencedBytes: '1.8T',
});
return { success: true, message: `Snapshot '${fullName}' created.` };
}
await this.runCommand('zfs', ['snapshot', fullName]);
return { success: true, message: `Snapshot '${fullName}' created.` };
}
async rollbackSnapshot(snapshotName: string): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
return { success: true, message: `Dataset rolled back to '${snapshotName}'.` };
}
await this.runCommand('zfs', ['rollback', '-r', snapshotName]);
return { success: true, message: `Dataset rolled back to '${snapshotName}'.` };
}
async destroySnapshot(snapshotName: string): Promise<{ success: boolean; message: string }> {
if (!config.isLinux) {
this.mockSnapshots = this.mockSnapshots.filter((s) => s.name !== snapshotName);
return { success: true, message: `Snapshot '${snapshotName}' destroyed.` };
}
await this.runCommand('zfs', ['destroy', snapshotName]);
return { success: true, message: `Snapshot '${snapshotName}' destroyed.` };
}
}
+200
View File
@@ -0,0 +1,200 @@
export type ZfsLayout = 'stripe' | 'mirror' | 'raidz1' | 'raidz2' | 'raidz3';
export interface ZfsDisk {
name: string;
path: string;
size: string;
health: string;
readErrors: number;
writeErrors: number;
checksumErrors: number;
}
export interface ZfsVdev {
name: string;
type: ZfsLayout | 'disk';
health: string;
disks: ZfsDisk[];
}
export interface ZfsPool {
name: string;
size: string;
allocated: string;
free: string;
fragmentation: string;
capacityPercent: number;
health: 'ONLINE' | 'DEGRADED' | 'FAULTED' | 'OFFLINE' | 'UNAVAIL';
altroot: string;
vdevs: ZfsVdev[];
scanStatus?: string;
scrubProgress?: number;
}
export interface ZfsDataset {
name: string;
pool: string;
used: string;
available: string;
referenced: string;
mountpoint: string;
compression: string;
quota: string;
reservation: string;
recordsize: string;
}
export interface ZfsSnapshot {
name: string;
dataset: string;
snapshotTag: string;
creationTime: string;
usedBytes: string;
referencedBytes: string;
}
export interface UnimportedPool {
name: string;
id: string;
state: string;
status: string;
action?: string;
disks: string[];
}
export interface SmbShare {
name: string;
path: string;
comment?: string;
readOnly: boolean;
browseable: boolean;
guestOk: boolean;
validUsers: string[];
forceUser?: string;
forceGroup?: string;
timeMachine: boolean;
timeMachineMaxSize?: string;
}
export interface NfsExportClient {
subnet: string;
options: string;
}
export interface NfsExport {
path: string;
clients: NfsExportClient[];
}
export interface SystemUser {
username: string;
description?: string;
isAdmin: boolean;
sshKeys: string[];
smbAccess: boolean;
}
export type AppRuntime = 'systemd' | 'docker' | 'k3s';
export interface AppCatalogItem {
id: string;
name: string;
category: 'Media' | 'Storage' | 'Productivity' | 'Security' | 'Automation';
description: string;
icon: string;
defaultPort: number;
supportedRuntimes: AppRuntime[];
recommendedRuntime: AppRuntime;
envVariables?: Record<string, string>;
}
export interface InstalledApp {
id: string;
appId: string;
name: string;
runtime: AppRuntime;
status: 'running' | 'stopped' | 'errored' | 'starting';
port: number;
version: string;
cpuUsagePercent: number;
memoryUsageBytes: number;
config: Record<string, unknown>;
}
export interface GitOpsStatus {
enabled: boolean;
remoteUrl?: string;
branch: string;
lastCommitSha: string;
lastCommitMessage: string;
lastSyncTime?: string;
pendingChanges: boolean;
isClean: boolean;
}
export interface GitCommit {
hash: string;
author: string;
date: string;
message: string;
}
export interface SystemStatus {
hostname: string;
uptimeSeconds: number;
cpuUsagePercent: number;
memoryTotalBytes: number;
memoryUsedBytes: number;
arcSizeBytes: number;
arcHitRatioPercent: number;
zfsPoolsCount: number;
activeSharesCount: number;
runningAppsCount: number;
osVersion: string;
nixosGeneration: number;
}
export interface NaxosConfig {
core: {
hostname: string;
timezone: string;
};
storage: {
arcMaxBytes: number;
autoScrub: boolean;
autoTrim: boolean;
pools: Record<string, {
layout: ZfsLayout;
devices: string[];
ashift: number;
datasets: Record<string, {
mountpoint: string;
compression: string;
recordsize: string;
quota?: string;
}>;
}>;
importExistingPools: string[];
};
shares: {
samba: {
enable: boolean;
workgroup: string;
shares: Record<string, SmbShare>;
};
nfs: {
enable: boolean;
exports: NfsExport[];
};
};
appEngine: {
defaultRuntime: AppRuntime;
apps: Record<string, InstalledApp>;
};
users: Record<string, SystemUser>;
gitops: {
remoteUrl?: string;
branch: string;
autoPushOnCommit: boolean;
};
}