Initial commit device-management
tests / ci (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-08-25 09:42:00 +07:00
commit 1e80be180e
1159 changed files with 149545 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import { Form, Head, Link } from '@inertiajs/react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Item = { id: number; name: string; slug?: string };
type Model = Item & { device_vendor_id: number; device_type_id: number };
type Props = {
device: (Record<string, unknown> & { connection_ports?: Record<string, number> }) | null;
vendors: Item[];
types: Item[];
models: Model[];
};
export default function DeviceForm({ device, vendors, types, models }: Props) {
const editing = !!device;
const initialVendor = vendors.find((vendor) => vendor.id === Number(device?.device_vendor_id));
const [vendorSlug, setVendorSlug] = useState(initialVendor?.slug ?? '');
const fields = [
['name', 'Nama Perangkat'],
['management_address', 'IP / Domain Management'],
['location', 'Lokasi'],
];
const protocolProfiles: Record<string, Array<[string, string, number]>> = {
mikrotik: [['routeros_api', 'Port RouterOS API', 8728]],
zte: [['ssh', 'Port SSH', 22]],
hsgq: [['ssh', 'Port SSH', 22], ['telnet', 'Port Telnet', 23]],
hisfocus: [['telnet', 'Port Telnet', 23], ['web_http', 'Port WebGUI HTTP', 80]],
};
const flexibleProtocols: Array<[string, string, number]> = [
['ssh', 'Port SSH', 22], ['telnet', 'Port Telnet', 23],
['web_http', 'Port WebGUI HTTP', 80], ['snmp', 'Port SNMP', 161],
['vendor_api', 'Port Vendor API', 0],
];
const protocols = protocolProfiles[vendorSlug] ?? flexibleProtocols;
return (
<>
<Head title={editing ? 'Edit Perangkat' : 'Tambah Perangkat'} />
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-4 md:p-6">
<div>
<h1 className="text-2xl font-semibold">
{editing ? 'Edit' : 'Tambah'} Perangkat
</h1>
<p className="text-sm text-muted-foreground">
Data teknis lainnya akan diambil otomatis saat perangkat
pertama kali terhubung.
</p>
</div>
<Form
action={editing ? `/devices/${device.id}` : '/devices'}
method={editing ? 'put' : 'post'}
>
{({ processing, errors }) => (
<Card>
<CardHeader>
<CardTitle>Informasi Perangkat</CardTitle>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
{fields.map(([name, label]) => (
<div key={name} className="grid gap-2">
<Label>{label}</Label>
<Input
name={name}
type="text"
defaultValue={String(
device?.[name] ?? '',
)}
required={[
'name',
'management_address',
].includes(name)}
/>
<InputError message={errors[name]} />
</div>
))}
<div className="grid gap-2">
<Label>Vendor</Label>
<select
name="device_vendor_id"
defaultValue={String(
device?.device_vendor_id ?? '',
)}
required
onChange={(event) => {
const selected = vendors.find((vendor) => vendor.id === Number(event.target.value));
setVendorSlug(selected?.slug ?? '');
}}
className="h-10 rounded-md border bg-background px-3"
>
<option value="">Pilih vendor</option>
{vendors.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
<InputError
message={errors.device_vendor_id}
/>
</div>
<div className="grid gap-2">
<Label>Tipe</Label>
<select
name="device_type_id"
defaultValue={String(
device?.device_type_id ?? '',
)}
required
className="h-10 rounded-md border bg-background px-3"
>
<option value="">Pilih tipe</option>
{types.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
<InputError
message={errors.device_type_id}
/>
</div>
<div className="grid gap-2">
<Label>Model</Label>
<select
name="device_model_id"
defaultValue={String(
device?.device_model_id ?? '',
)}
className="h-10 rounded-md border bg-background px-3"
>
<option value="">Tanpa model</option>
{models.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</div>
<div className="rounded-lg border p-4 md:col-span-2">
<h3 className="font-medium">Koneksi Management</h3>
<p className="mb-4 text-sm text-muted-foreground">Kosongkan port jika protokol tersebut tidak tersedia pada perangkat.</p>
<InputError message={errors.connection_ports} />
<div className="grid gap-4 md:grid-cols-2">
{protocols.map(([protocol, label, defaultPort]) => (
<div key={`${vendorSlug}-${protocol}`} className="grid gap-2">
<Label>{label}</Label>
<Input
name={`connection_ports[${protocol}]`}
type="number"
min={1}
max={65535}
placeholder={defaultPort ? String(defaultPort) : 'Port custom'}
defaultValue={device?.connection_ports?.[protocol] ?? ''}
/>
<InputError message={errors[`connection_ports.${protocol}`]} />
</div>
))}
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Catatan</Label>
<textarea
name="notes"
defaultValue={String(
device?.notes ?? '',
)}
className="min-h-24 rounded-md border bg-background p-3"
/>
</div>
{!editing && (
<>
<div className="grid gap-2">
<Label>Username Awal</Label>
<Input name="initial_username" required autoComplete="off" />
<InputError message={errors.initial_username} />
</div>
<div className="grid gap-2">
<Label>Password Awal</Label>
<Input name="initial_password" type="password" autoComplete="new-password" />
<p className="text-xs text-muted-foreground">Boleh kosong untuk perangkat MikroTik yang masih memakai password kosong.</p>
<InputError message={errors.initial_password} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Enable Password (opsional)</Label>
<Input name="initial_enable_password" type="password" autoComplete="new-password" />
<p className="text-xs text-muted-foreground">Khusus perangkat CLI seperti ZTE C300/C320. Default pabrik tertentu adalah zxr10; isi sesuai kondisi perangkat.</p>
<InputError message={errors.initial_enable_password} />
</div>
<label className="flex items-start gap-3 rounded-lg border p-4 md:col-span-2">
<input type="checkbox" name="remove_legacy_users_on_activation" value="1" className="mt-1" />
<span>
<span className="block font-medium">Hapus user lama setelah koneksi pertama</span>
<span className="block text-sm text-muted-foreground">Dijalankan hanya setelah Base User RADIQ berhasil dibuat dan diuji login. User grup full tetap dipertahankan untuk mencegah perangkat terkunci.</span>
</span>
</label>
</>
)}
<div className="flex justify-end gap-3 md:col-span-2">
<Button variant="outline" asChild>
<Link href="/devices">Batal</Link>
</Button>
<Button disabled={processing}>
Simpan Perangkat
</Button>
</div>
</CardContent>
</Card>
)}
</Form>
</div>
</>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { Form, Head, Link, router, usePage } from '@inertiajs/react';
import { Eye, Pencil, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
type Device = {
id: number;
name: string;
management_address: string;
management_port: number;
connection_type: string;
connection_ports?: Record<string, number>;
status: string;
location?: string;
vendor: { name: string };
type: { name: string };
model?: { name: string };
creator?: { name: string };
user_assignments_count: number;
};
type Props = {
devices: {
data: Device[];
links: { url: string | null; label: string; active: boolean }[];
};
vendors: { id: number; name: string }[];
filters: { search?: string; vendor?: string; status?: string };
policy: {
tenant_user_can_create: boolean;
tenant_user_can_update_own: boolean;
tenant_user_can_delete_own: boolean;
};
isTenantAdmin: boolean;
};
export default function DeviceIndex({
devices,
vendors,
filters,
policy,
isTenantAdmin,
}: Props) {
const { auth } = usePage().props;
const canCreate =
auth.permissions.includes('device.create') &&
(isTenantAdmin || policy.tenant_user_can_create);
const search = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
router.get('/devices', Object.fromEntries(data), {
preserveState: true,
});
};
return (
<>
<Head title="Perangkat" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">
Perangkat Jaringan
</h1>
<p className="text-sm text-muted-foreground">
Inventaris perangkat tenant yang terisolasi dan
aman.
</p>
</div>
{canCreate && (
<Button asChild>
<Link href="/devices/create">
<Plus className="size-4" />
Tambah Perangkat
</Link>
</Button>
)}
</div>
{isTenantAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Aturan Tenant User
</CardTitle>
</CardHeader>
<CardContent>
<Form
action="/device-policy"
method="put"
className="flex flex-wrap gap-5 text-sm"
>
{[
[
'tenant_user_can_create',
'Boleh menambah perangkat',
],
[
'tenant_user_can_update_own',
'Boleh mengubah perangkat sendiri',
],
[
'tenant_user_can_delete_own',
'Boleh menghapus perangkat sendiri',
],
].map(([name, label]) => (
<label
key={name}
className="flex items-center gap-2"
>
<input
type="hidden"
name={name}
value="0"
/>
<input
type="checkbox"
name={name}
value="1"
defaultChecked={
policy[
name as keyof typeof policy
]
}
/>
{label}
</label>
))}
<Button size="sm">Simpan Aturan</Button>
</Form>
</CardContent>
</Card>
)}
<Card>
<CardContent className="pt-6">
<form
onSubmit={search}
className="mb-5 grid gap-3 md:grid-cols-4"
>
<Input
name="search"
defaultValue={filters.search}
placeholder="Cari nama, alamat, lokasi..."
/>
<select
name="vendor"
defaultValue={filters.vendor ?? ''}
className="h-10 rounded-md border bg-background px-3"
>
<option value="">Semua vendor</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
<select
name="status"
defaultValue={filters.status ?? ''}
className="h-10 rounded-md border bg-background px-3"
>
<option value="">Semua status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="unknown">Unknown</option>
</select>
<Button variant="outline">Filter</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] table-fixed text-sm [&_td]:align-middle [&_td]:p-3 [&_th]:p-3">
<colgroup>
<col className="w-[20%]" />
<col className="w-[18%]" />
<col className="w-[16%]" />
<col className="w-[14%]" />
<col className="w-[10%]" />
<col className="w-[10%]" />
<col className="w-[12%]" />
</colgroup>
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="p-3">Perangkat</th>
<th className="p-3">Alamat</th>
<th className="p-3">User</th>
<th className="p-3">Vendor / Tipe</th>
<th className="p-3">Lokasi</th>
<th className="p-3">Status</th>
<th className="p-3 text-right">Aksi</th>
</tr>
</thead>
<tbody>
{devices.data.map((device) => (
<tr
key={device.id}
className="border-b"
>
<td className="p-3">
<div className="font-medium">
{device.name}
</div>
<div className="text-xs text-muted-foreground">
{device.model?.name ??
'Model belum ditentukan'}
</div>
</td>
<td className="break-words p-3">
<div>{device.management_address}</div>
<div className="text-xs text-muted-foreground">
{Object.entries(device.connection_ports ?? { [device.connection_type]: device.management_port }).map(([protocol, port]) => `${protocol}:${port}`).join(' · ')}
</div>
</td>
<td>
<Link
className="font-medium text-primary hover:underline"
href={`/device-users?device_id=${device.id}`}
>
{
device.user_assignments_count
}{' '}
user
</Link>
</td>
<td>
{device.vendor.name} ·{' '}
{device.type.name}
</td>
<td>{device.location ?? '-'}</td>
<td>
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">
{device.status}
</span>
</td>
<td>
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
asChild
>
<Link
href={`/devices/${device.id}`}
>
<Eye className="size-4" />
</Link>
</Button>
{auth.permissions.includes(
'device.update',
) && (
<Button
size="icon"
variant="ghost"
asChild
>
<Link
href={`/devices/${device.id}/edit`}
>
<Pencil className="size-4" />
</Link>
</Button>
)}
{auth.permissions.includes(
'device.delete',
) && (
<Button
size="icon"
variant="ghost"
onClick={() =>
confirm(
'Hapus perangkat ini?',
) &&
router.delete(
`/devices/${device.id}`,
)
}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{devices.data.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
Belum ada perangkat.
</div>
)}
</div>
</CardContent>
</Card>
</div>
</>
);
}
+360
View File
@@ -0,0 +1,360 @@
import { Form, Head } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Props = {
setting: {
base_username: string;
use_tls: boolean;
verify_tls: boolean;
connection_timeout: number;
} | null;
hasPassword: boolean;
encryptionKey: {
version: number;
source: string;
created_at: string;
} | null;
devices: {
id: number;
name: string;
base_sync_status: string;
base_sync_error_code?: string;
base_sync_message?: string;
base_synced_at?: string;
vendor: { name: string };
}[];
};
export default function DeviceSettings({
setting,
hasPassword,
encryptionKey,
devices,
}: Props) {
const [keyMode, setKeyMode] = useState('generated');
const [loginPassword, setLoginPassword] = useState('');
const [revealedPassword, setRevealedPassword] = useState('');
const [revealError, setRevealError] = useState('');
const [revealing, setRevealing] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
}, []);
const revealBasePassword = async () => {
setRevealing(true);
setRevealError('');
setRevealedPassword('');
try {
const csrf = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const response = await fetch('/device-settings/reveal-password', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify({ current_password: loginPassword }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.errors?.current_password?.[0] ?? result.message ?? 'Password tidak dapat ditampilkan.');
}
setRevealedPassword(result.password);
setLoginPassword('');
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
hideTimer.current = setTimeout(() => setRevealedPassword(''), 30000);
} catch (error) {
setRevealError(error instanceof Error ? error.message : 'Password tidak dapat ditampilkan.');
} finally {
setRevealing(false);
}
};
return (
<>
<Head title="Pengaturan Perangkat" />
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-4 md:p-6">
<div>
<h1 className="text-2xl font-semibold">
Base User Perangkat
</h1>
<p className="text-sm text-muted-foreground">
Akun management terpusat untuk seluruh perangkat tenant.
Perubahan username atau password akan disinkronkan ke
semua perangkat yang aktif.
</p>
</div>
<Form action="/device-settings" method="put">
{({ processing, errors }) => (
<Card>
<CardHeader>
<CardTitle>
Template Base User Tenant
</CardTitle>
<CardDescription>
Password disimpan terenkripsi dan tidak
pernah ditampilkan kembali.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<div className="grid gap-2">
<Label>Base Username</Label>
<Input
name="base_username"
defaultValue={
setting?.base_username ??
'radiq-manager'
}
required
/>
<InputError
message={errors.base_username}
/>
</div>
<div className="grid gap-2">
<Label>Connection Timeout (detik)</Label>
<Input
name="connection_timeout"
type="number"
min="3"
max="30"
defaultValue={
setting?.connection_timeout ?? 10
}
required
/>
</div>
<div className="grid gap-2">
<Label>
{hasPassword
? 'Password Baru (kosongkan jika tetap)'
: 'Base Password'}
</Label>
<Input
name="base_password"
type="password"
required={!hasPassword}
autoComplete="new-password"
/>
<InputError
message={errors.base_password}
/>
</div>
<div className="grid gap-2">
<Label>Konfirmasi Password</Label>
<Input
name="base_password_confirmation"
type="password"
required={!hasPassword}
autoComplete="new-password"
/>
</div>
<label className="flex items-center gap-2">
<input
type="hidden"
name="use_tls"
value="0"
/>
<input
type="checkbox"
name="use_tls"
value="1"
defaultChecked={setting?.use_tls}
/>
Gunakan API-SSL/TLS (umumnya port 8729)
</label>
<label className="flex items-center gap-2">
<input
type="hidden"
name="verify_tls"
value="0"
/>
<input
type="checkbox"
name="verify_tls"
value="1"
defaultChecked={
setting?.verify_tls ?? true
}
/>
Verifikasi sertifikat TLS
</label>
<div className="rounded-lg border bg-muted/40 p-4 text-sm md:col-span-2">
<strong>Group yang dibuat:</strong>{' '}
RADIQ-READ, RADIQ-WRITE, dan RADIQ-NOC tanpa
permission <code>policy</code>, serta
group bawaan full untuk akun provisioning
terpusat.
</div>
{hasPassword && (
<div className="grid gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950 md:col-span-2">
<div>
<strong>Lihat Base Password</strong>
<p>Masukkan password login Tenant Admin. Password perangkat akan disembunyikan otomatis setelah 30 detik.</p>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="password"
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
placeholder="Password login Tenant Admin"
autoComplete="current-password"
/>
<Button type="button" variant="outline" disabled={revealing || !loginPassword} onClick={revealBasePassword}>
{revealing ? 'Memverifikasi...' : 'Lihat Password'}
</Button>
</div>
{revealError && <p className="text-destructive">{revealError}</p>}
{revealedPassword && (
<Input value={revealedPassword} readOnly className="font-mono" aria-label="Base Password" />
)}
</div>
)}
<div className="flex justify-end md:col-span-2">
<Button disabled={processing}>
Simpan Pengaturan
</Button>
</div>
</CardContent>
</Card>
)}
</Form>
<Card>
<CardHeader>
<CardTitle>Status Rotasi Base User</CardTitle>
<CardDescription>
Perangkat offline akan dicoba kembali oleh scheduler.
Vendor tanpa driver ditandai unsupported.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{devices.map((device) => (
<div key={device.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm">
<div>
<div className="font-medium">{device.name}</div>
<div className="text-xs text-muted-foreground">{device.vendor.name} · {device.base_sync_message ?? 'Belum pernah disinkronkan'}</div>
</div>
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">{device.base_sync_status}</span>
</div>
))}
{devices.length === 0 && <p className="py-4 text-center text-sm text-muted-foreground">Belum ada perangkat.</p>}
</CardContent>
</Card>
<Form
action="/device-settings/rotate-key"
method="post"
resetOnSuccess
>
{({ processing, errors }) => (
<Card>
<CardHeader>
<CardTitle>Encryption Key Tenant</CardTitle>
<CardDescription>
Key aktif versi{' '}
{encryptionKey?.version ?? 1} (
{encryptionKey?.source ?? 'generated'}).
Rotasi mengenkripsi ulang seluruh password
perangkat dan Base Password pada database.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<div className="grid gap-2">
<Label>Metode Key Baru</Label>
<select
name="mode"
value={keyMode}
onChange={(event) =>
setKeyMode(event.target.value)
}
className="h-10 rounded-md border bg-background px-3"
>
<option value="generated">
Generate otomatis (direkomendasikan)
</option>
<option value="manual">
Masukkan manual
</option>
</select>
</div>
<div className="grid gap-2">
<Label>Password Tenant Admin</Label>
<Input
name="current_password"
type="password"
required
autoComplete="current-password"
/>
<InputError
message={errors.current_password}
/>
</div>
{keyMode === 'manual' && (
<>
<div className="grid gap-2">
<Label>
Key Manual (minimal 32 karakter)
</Label>
<Input
name="manual_key"
type="password"
minLength={32}
required
autoComplete="new-password"
/>
<InputError
message={errors.manual_key}
/>
</div>
<div className="grid gap-2">
<Label>Konfirmasi Key Manual</Label>
<Input
name="manual_key_confirmation"
type="password"
minLength={32}
required
autoComplete="new-password"
/>
</div>
</>
)}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 md:col-span-2">
Rotasi key tidak mengubah password yang
berada di router/OLT. Perubahan password
perangkat dilakukan melalui operasi rotasi
credential terpisah dan harus diverifikasi
login ulang.
</div>
<div className="flex justify-end md:col-span-2">
<Button
disabled={processing}
variant="destructive"
>
Rotasi Encryption Key
</Button>
</div>
</CardContent>
</Card>
)}
</Form>
</div>
</>
);
}
+317
View File
@@ -0,0 +1,317 @@
import { Form, Head, Link, router, usePage } from '@inertiajs/react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Credential = {
id: number;
name: string;
username: string;
connection_type: string;
privilege_type: string;
is_master: boolean;
is_active: boolean;
};
type Props = {
device: {
id: number;
name: string;
hostname?: string;
management_address: string;
management_port: number;
connection_type: string;
connection_ports?: Record<string, number>;
status: string;
location?: string;
serial_number?: string;
firmware_version?: string;
software_version?: string;
device_facts?: Record<string, string>;
notes?: string;
vendor: { name: string; slug: string };
activation_status: string;
activation_message?: string;
type: { name: string };
model?: { name: string };
creator?: { name: string };
};
credentials: Credential[];
};
export default function DeviceShow({ device, credentials }: Props) {
const { auth, errors } = usePage().props;
const rows = [
['Vendor', device.vendor.name],
['Tipe', device.type.name],
['Model', device.model?.name ?? '-'],
['Alamat Management', device.management_address],
['Koneksi', Object.entries(device.connection_ports ?? { [device.connection_type]: device.management_port }).map(([protocol, port]) => `${protocol}: ${port}`).join(' · ')],
['Hostname', device.hostname ?? '-'],
['Serial Number', device.serial_number ?? '-'],
['Versi RouterOS', device.software_version ?? '-'],
['Firmware', device.firmware_version ?? '-'],
[
'Board / Model Terdeteksi',
device.device_facts?.['board-name'] ??
device.device_facts?.model ??
'-',
],
['Arsitektur', device.device_facts?.['architecture-name'] ?? '-'],
['CPU', device.device_facts?.cpu ?? '-'],
['Jumlah CPU', device.device_facts?.['cpu-count'] ?? '-'],
['Total Memori', device.device_facts?.['total-memory'] ?? '-'],
['Uptime', device.device_facts?.uptime ?? '-'],
['Lokasi', device.location ?? '-'],
['Status', device.status],
['Dibuat Oleh', device.creator?.name ?? '-'],
];
return (
<>
<Head title={device.name} />
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-4 md:p-6">
<div className="flex justify-between">
<div>
<h1 className="text-2xl font-semibold">
{device.name}
</h1>
<p className="text-sm text-muted-foreground">
Detail inventaris perangkat
</p>
</div>
{auth.permissions.includes('device.update') && (
<Button asChild>
<Link href={`/devices/${device.id}/edit`}>
Edit Perangkat
</Link>
</Button>
)}
</div>
{['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes(device.vendor.slug) && (
<Card>
<CardHeader>
<CardTitle>
Aktivasi {device.vendor.name}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="text-sm">
<p>
Status:{' '}
<strong className="uppercase">
{device.activation_status}
</strong>
</p>
<p className="text-muted-foreground">
{device.activation_message ??
'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'}
</p>
{errors.activation && (
<p className="mt-2 text-destructive">
{errors.activation}
</p>
)}
</div>
{auth.permissions.includes(
'device.connect',
) && (
<Form
action={`/devices/${device.id}/activate`}
method="post"
>
{({ processing }) => (
<Button disabled={processing}>
{processing
? 'Mengaktifkan...'
: 'Cek & Aktifkan Perangkat'}
</Button>
)}
</Form>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Informasi</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
{rows.map(([label, value]) => (
<div key={label} className="border-b pb-3">
<div className="text-xs text-muted-foreground">
{label}
</div>
<div className="font-medium">{value}</div>
</div>
))}
{device.notes && (
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground">
Catatan
</div>
<p>{device.notes}</p>
</div>
)}
</CardContent>
</Card>
{auth.permissions.includes('device.credential.view') && (
<Card>
<CardHeader>
<CardTitle>Credential Perangkat</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2">Nama</th>
<th>Username</th>
<th>Password</th>
<th>Protokol</th>
<th>Privilege</th>
<th></th>
</tr>
</thead>
<tbody>
{credentials.map((item) => (
<tr
key={item.id}
className="border-b"
>
<td className="py-3">
{item.name}
{item.is_master && (
<span className="ml-2 rounded bg-amber-100 px-2 py-1 text-xs text-amber-800">
MASTER
</span>
)}
</td>
<td>{item.username}</td>
<td>••••••••••••</td>
<td>{item.connection_type}</td>
<td>{item.privilege_type}</td>
<td className="text-right">
{auth.permissions.includes(
'device.credential.delete',
) && (
<Button
size="icon"
variant="ghost"
onClick={() =>
confirm(
'Hapus credential ini?',
) &&
router.delete(
`/devices/${device.id}/credentials/${item.id}`,
)
}
>
<Trash2 className="size-4" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{credentials.length === 0 && (
<p className="py-5 text-center text-muted-foreground">
Belum ada credential.
</p>
)}
</div>
{auth.permissions.includes(
'device.credential.create',
) && (
<Form
action={`/devices/${device.id}/credentials`}
method="post"
resetOnSuccess
className="grid gap-4 border-t pt-5 md:grid-cols-3"
>
<div>
<Label>Nama Credential</Label>
<Input name="name" required />
</div>
<div>
<Label>Username</Label>
<Input name="username" required />
</div>
<div>
<Label>Password</Label>
<Input
name="password"
type="password"
required
autoComplete="new-password"
/>
</div>
<div>
<Label>Enable Password (ZTE/CLI)</Label>
<Input
name="enable_password"
type="password"
autoComplete="new-password"
/>
</div>
<div>
<Label>Protokol</Label>
<select
name="connection_type"
defaultValue={
device.connection_type
}
className="h-10 w-full rounded-md border bg-background px-3"
>
<option value="routeros_api">
RouterOS API
</option>
<option value="ssh">SSH</option>
<option value="telnet">
Telnet
</option>
<option value="snmp">SNMP</option>
<option value="vendor_api">
Vendor API
</option>
</select>
</div>
<div>
<Label>Privilege</Label>
<select
name="privilege_type"
defaultValue="custom"
className="h-10 w-full rounded-md border bg-background px-3"
>
<option value="master">
Master
</option>
<option value="noc">NOC</option>
<option value="technician">
Technician
</option>
<option value="monitoring">
Monitoring
</option>
<option value="custom">
Custom
</option>
</select>
</div>
<div className="flex items-end">
<Button>Simpan Credential</Button>
</div>
</Form>
)}
</CardContent>
</Card>
)}
<Button variant="outline" asChild>
<Link href="/devices">Kembali ke Daftar</Link>
</Button>
</div>
</>
);
}