@ -7,6 +7,8 @@ const state = {
limit : 25 ,
limit : 25 ,
total : 0 ,
total : 0 ,
pages : 1 ,
pages : 1 ,
sort : "last_seen_at" ,
order : "desc" ,
selectedId : null ,
selectedId : null ,
searchTimer : null ,
searchTimer : null ,
options : { device _status : [ ] , asset _for : [ ] , asset _on : [ ] } ,
options : { device _status : [ ] , asset _for : [ ] , asset _on : [ ] } ,
@ -20,6 +22,16 @@ const el = {
apiToken : document . getElementById ( "api-token" ) ,
apiToken : document . getElementById ( "api-token" ) ,
stats : document . getElementById ( "stats" ) ,
stats : document . getElementById ( "stats" ) ,
searchQ : document . getElementById ( "search-q" ) ,
searchQ : document . getElementById ( "search-q" ) ,
searchIn : document . getElementById ( "search-in" ) ,
filterForm : document . getElementById ( "filter-form" ) ,
filterOwner : document . getElementById ( "filter-owner" ) ,
filterModel : document . getElementById ( "filter-model" ) ,
filterSeries : document . getElementById ( "filter-series" ) ,
filterManufacturer : document . getElementById ( "filter-manufacturer" ) ,
filterDeviceStatus : document . getElementById ( "filter-device-status" ) ,
filterAssetFor : document . getElementById ( "filter-asset-for" ) ,
filterAssetOn : document . getElementById ( "filter-asset-on" ) ,
clearFilters : document . getElementById ( "clear-filters" ) ,
filterType : document . getElementById ( "filter-type" ) ,
filterType : document . getElementById ( "filter-type" ) ,
filterStale : document . getElementById ( "filter-stale" ) ,
filterStale : document . getElementById ( "filter-stale" ) ,
listContent : document . getElementById ( "list-content" ) ,
listContent : document . getElementById ( "list-content" ) ,
@ -33,6 +45,17 @@ const el = {
detailTitle : document . getElementById ( "detail-title" ) ,
detailTitle : document . getElementById ( "detail-title" ) ,
detailContent : document . getElementById ( "detail-content" ) ,
detailContent : document . getElementById ( "detail-content" ) ,
closeDetail : document . getElementById ( "close-detail" ) ,
closeDetail : document . getElementById ( "close-detail" ) ,
toggleCatalog : document . getElementById ( "toggle-catalog" ) ,
catalogPanel : document . getElementById ( "catalog-panel" ) ,
catalogForm : document . getElementById ( "catalog-form" ) ,
catalogContent : document . getElementById ( "catalog-content" ) ,
catalogMeta : document . getElementById ( "catalog-meta" ) ,
catalogSync : document . getElementById ( "catalog-sync" ) ,
catalogPendingOnly : document . getElementById ( "catalog-pending-only" ) ,
catManufacturer : document . getElementById ( "cat-manufacturer" ) ,
catModel : document . getElementById ( "cat-model" ) ,
catSeries : document . getElementById ( "cat-series" ) ,
catNotes : document . getElementById ( "cat-notes" ) ,
} ;
} ;
function loadSettings ( ) {
function loadSettings ( ) {
@ -79,14 +102,17 @@ async function api(path) {
async function apiMutate ( method , path , body ) {
async function apiMutate ( method , path , body ) {
const url = ` ${ state . baseUrl . replace ( /\/$/ , "" ) } ${ path } ` ;
const url = ` ${ state . baseUrl . replace ( /\/$/ , "" ) } ${ path } ` ;
const res = await fetch ( url , {
const init = {
method ,
method ,
headers : {
headers : {
Authorization : ` Bearer ${ state . token } ` ,
Authorization : ` Bearer ${ state . token } ` ,
"Content-Type" : "application/json" ,
} ,
} ,
body : JSON . stringify ( body ) ,
} ;
} ) ;
if ( body !== undefined ) {
init . headers [ "Content-Type" ] = "application/json" ;
init . body = JSON . stringify ( body ) ;
}
const res = await fetch ( url , init ) ;
const text = await res . text ( ) ;
const text = await res . text ( ) ;
let data ;
let data ;
try { data = text ? JSON . parse ( text ) : null ; } catch { data = { raw : text } ; }
try { data = text ? JSON . parse ( text ) : null ; } catch { data = { raw : text } ; }
@ -100,11 +126,41 @@ async function apiMutate(method, path, body) {
async function loadOptions ( ) {
async function loadOptions ( ) {
try {
try {
state . options = await api ( "/api/v1/assets/options" ) ;
state . options = await api ( "/api/v1/assets/options" ) ;
populateFilterSelect ( el . filterDeviceStatus , state . options . device _status ) ;
populateFilterSelect ( el . filterAssetFor , state . options . asset _for ) ;
populateFilterSelect ( el . filterAssetOn , state . options . asset _on ) ;
} catch {
} catch {
state . options = { device _status : [ ] , asset _for : [ ] , asset _on : [ ] } ;
state . options = { device _status : [ ] , asset _for : [ ] , asset _on : [ ] } ;
}
}
}
}
function populateFilterSelect ( selectEl , options ) {
if ( ! selectEl ) return ;
const current = selectEl . value ;
selectEl . innerHTML = '<option value="">Semua</option>' ;
for ( const opt of options || [ ] ) {
const o = document . createElement ( "option" ) ;
o . value = opt ;
o . textContent = opt ;
selectEl . appendChild ( o ) ;
}
if ( current ) selectEl . value = current ;
}
function resetFilters ( ) {
if ( el . filterOwner ) el . filterOwner . value = "" ;
if ( el . filterModel ) el . filterModel . value = "" ;
if ( el . filterSeries ) el . filterSeries . value = "" ;
if ( el . filterManufacturer ) el . filterManufacturer . value = "" ;
if ( el . filterDeviceStatus ) el . filterDeviceStatus . value = "" ;
if ( el . filterAssetFor ) el . filterAssetFor . value = "" ;
if ( el . filterAssetOn ) el . filterAssetOn . value = "" ;
if ( el . searchQ ) el . searchQ . value = "" ;
if ( el . searchIn ) el . searchIn . value = "all" ;
state . page = 1 ;
loadAssets ( ) . catch ( showError ) ;
}
function fmtDate ( value ) {
function fmtDate ( value ) {
if ( ! value ) return "—" ;
if ( ! value ) return "—" ;
try {
try {
@ -125,6 +181,7 @@ function escapeHtml(str) {
function showConnectedUi ( show ) {
function showConnectedUi ( show ) {
el . stats . classList . toggle ( "hidden" , ! show ) ;
el . stats . classList . toggle ( "hidden" , ! show ) ;
el . searchForm . classList . toggle ( "hidden" , ! show ) ;
el . searchForm . classList . toggle ( "hidden" , ! show ) ;
if ( el . filterForm ) el . filterForm . classList . toggle ( "hidden" , ! show ) ;
}
}
function renderStats ( data ) {
function renderStats ( data ) {
@ -150,18 +207,70 @@ function buildAssetsQuery() {
const params = new URLSearchParams ( ) ;
const params = new URLSearchParams ( ) ;
params . set ( "page" , String ( state . page ) ) ;
params . set ( "page" , String ( state . page ) ) ;
params . set ( "limit" , String ( state . limit ) ) ;
params . set ( "limit" , String ( state . limit ) ) ;
const q = el . searchQ . value . trim ( ) ;
params . set ( "sort" , state . sort ) ;
params . set ( "order" , state . order ) ;
const q = el . searchQ ? . value . trim ( ) ;
if ( q ) params . set ( "q" , q ) ;
if ( q ) params . set ( "q" , q ) ;
const searchIn = el . searchIn ? . value ;
if ( searchIn && searchIn !== "all" ) params . set ( "search_in" , searchIn ) ;
if ( el . filterOwner ? . value . trim ( ) ) params . set ( "owner" , el . filterOwner . value . trim ( ) ) ;
if ( el . filterModel ? . value . trim ( ) ) params . set ( "model" , el . filterModel . value . trim ( ) ) ;
if ( el . filterSeries ? . value . trim ( ) ) params . set ( "series" , el . filterSeries . value . trim ( ) ) ;
if ( el . filterManufacturer ? . value . trim ( ) ) params . set ( "manufacturer" , el . filterManufacturer . value . trim ( ) ) ;
if ( el . filterDeviceStatus ? . value ) params . set ( "device_status" , el . filterDeviceStatus . value ) ;
if ( el . filterAssetFor ? . value ) params . set ( "asset_for" , el . filterAssetFor . value ) ;
if ( el . filterAssetOn ? . value ) params . set ( "asset_on" , el . filterAssetOn . value ) ;
const type = el . filterType . value ;
const type = el . filterType . value ;
if ( type ) params . set ( "type" , type ) ;
if ( type ) params . set ( "type" , type ) ;
if ( el . filterStale . checked ) params . set ( "stale" , "true" ) ;
if ( el . filterStale . checked ) params . set ( "stale" , "true" ) ;
return ` ? ${ params . toString ( ) } ` ;
return ` ? ${ params . toString ( ) } ` ;
}
}
function sortTh ( field , label ) {
const active = state . sort === field ;
const arrow = active ? ( state . order === "asc" ? " ▲" : " ▼" ) : "" ;
return ` <th class="sortable ${ active ? " sort-active" : "" } " data-sort=" ${ field } "> ${ escapeHtml ( label ) } ${ arrow } </th> ` ;
}
function bindSortHeaders ( ) {
el . listContent . querySelectorAll ( "th.sortable" ) . forEach ( ( th ) => {
th . addEventListener ( "click" , ( e ) => {
e . stopPropagation ( ) ;
const field = th . dataset . sort ;
if ( state . sort === field ) {
state . order = state . order === "asc" ? "desc" : "asc" ;
} else {
state . sort = field ;
state . order = field === "last_seen_at" ? "desc" : "asc" ;
}
state . page = 1 ;
loadAssets ( ) . catch ( showError ) ;
} ) ;
} ) ;
}
function assetLabel ( item ) {
function assetLabel ( item ) {
return item . display _name || item . hostname ;
return item . display _name || item . hostname ;
}
}
function formatOwner ( item ) {
const username = item . owner _ldap _username ;
const fullname = item . owner _display _name ;
if ( fullname && username ) {
return ` <strong> ${ escapeHtml ( fullname ) } </strong><div class="muted small"> ${ escapeHtml ( username ) } </div> ` ;
}
if ( fullname ) return ` <strong> ${ escapeHtml ( fullname ) } </strong> ` ;
if ( username ) return ` <span class="badge af-geonet"> ${ escapeHtml ( username ) } </span> ` ;
return ` <span class="muted">—</span> ` ;
}
function formatOwnerPlain ( item ) {
const username = item . owner _ldap _username ;
const fullname = item . owner _display _name ;
if ( fullname && username ) return ` ${ fullname } ( ${ username } ) ` ;
return fullname || username || "—" ;
}
function formFactorBadge ( value ) {
function formFactorBadge ( value ) {
if ( ! value ) return "" ;
if ( ! value ) return "" ;
const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value ;
const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value ;
@ -258,6 +367,10 @@ function bindEditPanel(asset) {
const saveBtn = document . getElementById ( "save-meta-btn" ) ;
const saveBtn = document . getElementById ( "save-meta-btn" ) ;
const editStatus = document . getElementById ( "edit-status" ) ;
const editStatus = document . getElementById ( "edit-status" ) ;
if ( ownerInput && asset . owner _display _name ) {
ownerInput . dataset . ownerDisplayName = asset . owner _display _name ;
}
if ( ownerInput ) {
if ( ownerInput ) {
ownerInput . addEventListener ( "input" , ( ) => {
ownerInput . addEventListener ( "input" , ( ) => {
clearTimeout ( state . ldapSearchTimer ) ;
clearTimeout ( state . ldapSearchTimer ) ;
@ -273,7 +386,7 @@ function bindEditPanel(asset) {
suggestionsBox . innerHTML = ` <div class="ldap-item muted">Tidak ada hasil.</div> ` ;
suggestionsBox . innerHTML = ` <div class="ldap-item muted">Tidak ada hasil.</div> ` ;
} else {
} else {
suggestionsBox . innerHTML = results . map ( u => `
suggestionsBox . innerHTML = results . map ( u => `
< div class = "ldap-item" data - username = "${escapeHtml(u.username)}" >
< div class = "ldap-item" data - username = "${escapeHtml(u.username)}" data - display - name = "${escapeHtml(u.display_name || " ")}" >
< strong > $ { escapeHtml ( u . username ) } < / s t r o n g >
< strong > $ { escapeHtml ( u . username ) } < / s t r o n g >
$ { u . display _name ? ` <span class="muted"> — ${ escapeHtml ( u . display _name ) } </span> ` : "" }
$ { u . display _name ? ` <span class="muted"> — ${ escapeHtml ( u . display _name ) } </span> ` : "" }
$ { u . department ? ` <span class="muted small"> · ${ escapeHtml ( u . department ) } </span> ` : "" }
$ { u . department ? ` <span class="muted small"> · ${ escapeHtml ( u . department ) } </span> ` : "" }
@ -281,6 +394,7 @@ function bindEditPanel(asset) {
suggestionsBox . querySelectorAll ( ".ldap-item" ) . forEach ( item => {
suggestionsBox . querySelectorAll ( ".ldap-item" ) . forEach ( item => {
item . addEventListener ( "click" , ( ) => {
item . addEventListener ( "click" , ( ) => {
ownerInput . value = item . dataset . username ;
ownerInput . value = item . dataset . username ;
ownerInput . dataset . ownerDisplayName = item . dataset . displayName || "" ;
suggestionsBox . classList . add ( "hidden" ) ;
suggestionsBox . classList . add ( "hidden" ) ;
} ) ;
} ) ;
} ) ;
} ) ;
@ -320,7 +434,18 @@ function bindEditPanel(asset) {
const prevOwner = asset . owner _ldap _username || "" ;
const prevOwner = asset . owner _ldap _username || "" ;
if ( newOwner !== prevOwner ) {
if ( newOwner !== prevOwner ) {
if ( newOwner ) {
if ( newOwner ) {
await apiMutate ( "PUT" , ` /api/v1/assets/ ${ asset . id } /owner ` , { ldap _username : newOwner } ) ;
const ownerBody = { ldap _username : newOwner } ;
const pickedName = ownerInput ? . dataset ? . ownerDisplayName ? . trim ( ) ;
if ( pickedName ) ownerBody . display _name = pickedName ;
await apiMutate ( "PUT" , ` /api/v1/assets/ ${ asset . id } /owner ` , ownerBody ) ;
}
} else if ( newOwner && ownerInput ? . dataset ? . ownerDisplayName ? . trim ( ) ) {
const pickedName = ownerInput . dataset . ownerDisplayName . trim ( ) ;
if ( pickedName !== ( asset . owner _display _name || "" ) ) {
await apiMutate ( "PUT" , ` /api/v1/assets/ ${ asset . id } /owner ` , {
ldap _username : newOwner ,
display _name : pickedName ,
} ) ;
}
}
}
}
@ -357,10 +482,12 @@ function renderAssets(data) {
const aoBadge = item . asset _on ? tagBadge ( item . asset _on , ASSET _ON _COLORS ) : "" ;
const aoBadge = item . asset _on ? tagBadge ( item . asset _on , ASSET _ON _COLORS ) : "" ;
return ` <tr data-id=" ${ escapeHtml ( item . id ) } " class=" ${ selected } ">
return ` <tr data-id=" ${ escapeHtml ( item . id ) } " class=" ${ selected } ">
< td > < strong > $ { escapeHtml ( assetLabel ( item ) ) } < / s t r o n g > $ { i t e m . d i s p l a y _ n a m e ? ` < d i v c l a s s = " m u t e d s m a l l " > $ { e s c a p e H t m l ( i t e m . h o s t n a m e ) } < / d i v > ` : " " } $ { f o r m F a c t o r B a d g e ( i t e m . f o r m _ f a c t o r ) } < / t d >
< td > < strong > $ { escapeHtml ( assetLabel ( item ) ) } < / s t r o n g > $ { i t e m . d i s p l a y _ n a m e ? ` < d i v c l a s s = " m u t e d s m a l l " > $ { e s c a p e H t m l ( i t e m . h o s t n a m e ) } < / d i v > ` : " " } $ { f o r m F a c t o r B a d g e ( i t e m . f o r m _ f a c t o r ) } < / t d >
< td > < span class = "badge endpoint" > $ { escapeHtml ( item . asset _type ) } < / s p a n > < / t d >
< td class = "owner-cell" > $ { formatOwner ( item ) } < / t d >
< td class = "muted" > $ { escapeHtml ( item . model || "—" ) } < / t d >
< td > $ { item . series ? ` <span class="series-name"> ${ escapeHtml ( item . series ) } </span> ` : ` <span class="muted">—</span> ` } < / t d >
< td class = "muted" > $ { item . agent _version ? ` <code class="agent-ver"> ${ escapeHtml ( item . agent _version ) } </code> ` : "—" } < / t d >
< td > $ { dsBadge || ` <span class="badge active"> ${ escapeHtml ( item . status ) } </span> ` } < / t d >
< td > $ { dsBadge || ` <span class="badge active"> ${ escapeHtml ( item . status ) } </span> ` } < / t d >
< td > $ { afBadge } $ { aoBadge } < / t d >
< td > $ { afBadge } $ { aoBadge } < / t d >
< td class = "muted" > $ { escapeHtml ( item . serial _number || "—" ) } < / t d >
< td class = "muted" > $ { escapeHtml ( fmtDate ( item . last _seen _at ) ) } < / t d >
< td class = "muted" > $ { escapeHtml ( fmtDate ( item . last _seen _at ) ) } < / t d >
< / t r > ` ;
< / t r > ` ;
} )
} )
@ -371,12 +498,14 @@ function renderAssets(data) {
< table >
< table >
< thead >
< thead >
< tr >
< tr >
< th > Hostname < / t h >
$ { sortTh ( "hostname" , "Hostname" ) }
< th > Tipe < / t h >
$ { sortTh ( "owner" , "Owner" ) }
< th > Status < / t h >
$ { sortTh ( "model" , "Model" ) }
$ { sortTh ( "series" , "Series" ) }
$ { sortTh ( "agent_version" , "Agent" ) }
$ { sortTh ( "device_status" , "Status" ) }
< th > Tag < / t h >
< th > Tag < / t h >
< th > Serial < / t h >
$ { sortTh ( "last_seen_at" , "Last seen" ) }
< th > Last seen < / t h >
< / t r >
< / t r >
< / t h e a d >
< / t h e a d >
< tbody > $ { rows } < / t b o d y >
< tbody > $ { rows } < / t b o d y >
@ -385,6 +514,7 @@ function renderAssets(data) {
el . listContent . querySelectorAll ( "tbody tr" ) . forEach ( ( row ) => {
el . listContent . querySelectorAll ( "tbody tr" ) . forEach ( ( row ) => {
row . addEventListener ( "click" , ( ) => openDetail ( row . dataset . id ) ) ;
row . addEventListener ( "click" , ( ) => openDetail ( row . dataset . id ) ) ;
} ) ;
} ) ;
bindSortHeaders ( ) ;
el . pager . classList . remove ( "hidden" ) ;
el . pager . classList . remove ( "hidden" ) ;
el . pageInfo . textContent = ` Halaman ${ state . page } / ${ state . pages } ` ;
el . pageInfo . textContent = ` Halaman ${ state . page } / ${ state . pages } ` ;
@ -649,13 +779,13 @@ function renderDetail(asset, software) {
asset . asset _on ? tagBadge ( asset . asset _on , ASSET _ON _COLORS ) : "" ,
asset . asset _on ? tagBadge ( asset . asset _on , ASSET _ON _COLORS ) : "" ,
] . filter ( Boolean ) . join ( " " ) ;
] . filter ( Boolean ) . join ( " " ) ;
const ownerHtml = asset . owner _ldap _username
const ownerHtml = formatOwner ( asset ) ;
? ` <span class="badge af-geonet"> ${ escapeHtml ( asset . owner _ldap _username ) } </span> `
: ` <span class="muted">—</span> ` ;
const identity = [
const identity = [
[ "Hostname" , asset . hostname ] ,
[ "Hostname" , asset . hostname ] ,
[ "Display name" , asset . display _name ] ,
[ "Display name" , asset . display _name ] ,
[ "Owner" , formatOwnerPlain ( asset ) ] ,
[ "Agent version" , snap ? . agent _version || asset . metadata ? . agent _version ] ,
[ "Form factor" , formFactor ] ,
[ "Form factor" , formFactor ] ,
[ "Chassis" , snap ? . system ? . chassis _label || asset . metadata ? . chassis _label ] ,
[ "Chassis" , snap ? . system ? . chassis _label || asset . metadata ? . chassis _label ] ,
[ "Asset ID" , asset . id ] ,
[ "Asset ID" , asset . id ] ,
@ -665,15 +795,16 @@ function renderDetail(asset, software) {
[ "MAC" , asset . primary _physical _mac ] ,
[ "MAC" , asset . primary _physical _mac ] ,
[ "Manufacturer" , asset . manufacturer ] ,
[ "Manufacturer" , asset . manufacturer ] ,
[ "Model" , asset . model ] ,
[ "Model" , asset . model ] ,
[ "Series" , asset . series ] ,
[ "Snapshots" , asset . snapshot _count ] ,
[ "Snapshots" , asset . snapshot _count ] ,
[ "Last seen" , fmtDate ( asset . last _seen _at ) ] ,
[ "Last seen" , fmtDate ( asset . last _seen _at ) ] ,
] ;
] ;
let html = ` ` ;
let html = ` ` ;
if ( tagRow || asset . owner _ldap _username ) {
if ( tagRow || asset . owner _ldap _username || asset . owner _display _name ) {
html += ` <div class="asset-tags-row">
html += ` <div class="asset-tags-row">
$ { ownerHtml ? ` <span class="muted small">Owner:</span> ${ ownerHtml } ` : "" }
$ { ( asset . owner _ldap _username || asset . owner _display _name ) ? ` <span class="muted small">Owner:</span> ${ ownerHtml } ` : "" }
$ { tagRow ? ` ${ tagRow } ` : "" }
$ { tagRow ? ` ${ tagRow } ` : "" }
$ { asset . notes ? ` <div class="muted small notes-preview"> ${ escapeHtml ( asset . notes ) } </div> ` : "" }
$ { asset . notes ? ` <div class="muted small notes-preview"> ${ escapeHtml ( asset . notes ) } </div> ` : "" }
< / d i v > ` ;
< / d i v > ` ;
@ -685,6 +816,23 @@ function renderDetail(asset, software) {
)
)
. join ( "" ) } < / d l > ` ;
. join ( "" ) } < / d l > ` ;
if ( asset . manufacturer && asset . model && ! asset . series ) {
html += ` <div class="catalog-quick-add" id="catalog-quick-add">
< div class = "section-title" > Tambah Series ke SSOT < / d i v >
< p class = "muted small" > Series belum diisi untuk model < strong > $ { escapeHtml ( asset . model ) } < / s t r o n g > — l e n g k a p i d i c a t a l o g . < / p >
< div class = "catalog-form inline" >
< input type = "hidden" id = "quick-mfr" value = "${escapeHtml(asset.manufacturer)}" / >
< input type = "hidden" id = "quick-mdl" value = "${escapeHtml(asset.model)}" / >
< div class = "field" style = "flex:1" >
< label for = "quick-series" > Series < / l a b e l >
< input id = "quick-series" placeholder = "Lenovo Legion 5-15IMH05" / >
< / d i v >
< button type = "button" id = "quick-series-btn" > Simpan < / b u t t o n >
< / d i v >
< span id = "quick-series-status" class = "edit-status" > < / s p a n >
< / d i v > ` ;
}
html += renderEditPanel ( asset ) ;
html += renderEditPanel ( asset ) ;
if ( snap ) {
if ( snap ) {
@ -735,6 +883,121 @@ function renderDetail(asset, software) {
el . detailTitle . innerHTML = ` ${ escapeHtml ( asset . display _name || asset . hostname ) } ${ titleBadges ? " " + titleBadges : "" } ` ;
el . detailTitle . innerHTML = ` ${ escapeHtml ( asset . display _name || asset . hostname ) } ${ titleBadges ? " " + titleBadges : "" } ` ;
el . detailContent . innerHTML = html ;
el . detailContent . innerHTML = html ;
bindEditPanel ( asset ) ;
bindEditPanel ( asset ) ;
bindQuickSeriesAdd ( asset ) ;
}
function bindQuickSeriesAdd ( asset ) {
const btn = document . getElementById ( "quick-series-btn" ) ;
const input = document . getElementById ( "quick-series" ) ;
const status = document . getElementById ( "quick-series-status" ) ;
if ( ! btn || ! input ) return ;
btn . addEventListener ( "click" , async ( ) => {
const series = input . value . trim ( ) ;
if ( ! series ) {
status . textContent = "Isi series dulu." ;
status . className = "edit-status error" ;
return ;
}
btn . disabled = true ;
status . textContent = "Menyimpan..." ;
status . className = "edit-status" ;
try {
await apiMutate ( "POST" , "/api/v1/device-models" , {
manufacturer : asset . manufacturer ,
model : asset . model ,
series ,
source : "manual" ,
notes : document . getElementById ( "cat-notes" ) ? . value || null ,
} ) ;
status . textContent = "Tersimpan." ;
status . className = "edit-status ok" ;
await loadCatalog ( ) ;
openDetail ( asset . id ) ;
} catch ( err ) {
status . textContent = err . message ;
status . className = "edit-status error" ;
} finally {
btn . disabled = false ;
}
} ) ;
}
async function loadCatalog ( ) {
if ( ! el . catalogContent ) return ;
el . catalogContent . className = "loading" ;
el . catalogContent . textContent = "Memuat catalog..." ;
try {
const pendingOnly = el . catalogPendingOnly ? . checked ? "&pending_only=true" : "" ;
const data = await api ( ` /api/v1/device-models?limit=200 ${ pendingOnly } ` ) ;
const pendingCount = data . items ? . filter ( ( row ) => row . series _pending ) . length ? ? 0 ;
el . catalogMeta . textContent = ` ${ data . total } mapping ${ pendingCount ? ` · ${ pendingCount } perlu series ` : "" } ` ;
if ( ! data . items ? . length ) {
el . catalogContent . className = "empty" ;
el . catalogContent . textContent = pendingOnly
? "Semua model sudah punya series."
: "Belum ada mapping. Klik Sync dari assets atau tambahkan manual di form atas." ;
return ;
}
el . catalogContent . className = "" ;
el . catalogContent . innerHTML = `
< table >
< thead >
< tr > < th > Manufacturer < / t h > < t h > M o d e l < / t h > < t h > S e r i e s < / t h > < t h > C a t a t a n < / t h > < t h > < / t h > < / t r >
< / t h e a d >
< tbody >
$ { data . items . map ( ( row ) => {
const seriesCell = row . series _pending
? ` <div class="catalog-inline-series">
< input type = "text" class = "pending-series-input" data - id = "${escapeHtml(row.id)}" placeholder = "Isi series..." / >
< button type = "button" class = "secondary small-btn save-pending-series" data - id = "${escapeHtml(row.id)}" > Simpan < / b u t t o n >
< / d i v > `
: ` <strong> ${ escapeHtml ( row . series ) } </strong> ` ;
const rowClass = row . series _pending ? ' class="row-pending"' : "" ;
return `
< tr$ { rowClass } >
< td > $ { escapeHtml ( row . manufacturer ) } < / t d >
< td > < code > $ { escapeHtml ( row . model ) } < / c o d e > < / t d >
< td > $ { seriesCell } < / t d >
< td class = "muted" > $ { escapeHtml ( row . notes || "—" ) } < / t d >
< td > < button type = "button" class = "secondary small-btn" data - del - catalog = "${escapeHtml(row.id)}" > Hapus < / b u t t o n > < / t d >
< / t r > ` ;
} ) . join ( "" ) }
< / t b o d y >
< / t a b l e > ` ;
el . catalogContent . querySelectorAll ( "[data-del-catalog]" ) . forEach ( ( btn ) => {
btn . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( "Hapus mapping ini?" ) ) return ;
await apiMutate ( "DELETE" , ` /api/v1/device-models/ ${ btn . dataset . delCatalog } ` ) ;
await loadCatalog ( ) ;
loadAssets ( ) . catch ( showError ) ;
} ) ;
} ) ;
el . catalogContent . querySelectorAll ( ".save-pending-series" ) . forEach ( ( btn ) => {
btn . addEventListener ( "click" , async ( ) => {
const id = btn . dataset . id ;
const input = el . catalogContent . querySelector ( ` .pending-series-input[data-id=" ${ id } "] ` ) ;
const series = input ? . value . trim ( ) ;
if ( ! series ) {
alert ( "Isi series dulu." ) ;
return ;
}
btn . disabled = true ;
try {
await apiMutate ( "PATCH" , ` /api/v1/device-models/ ${ id } ` , { series , source : "manual" } ) ;
await Promise . all ( [ loadCatalog ( ) , loadAssets ( ) ] ) ;
if ( state . selectedId ) openDetail ( state . selectedId ) ;
} catch ( err ) {
alert ( err . message ) ;
} finally {
btn . disabled = false ;
}
} ) ;
} ) ;
} catch ( err ) {
el . catalogContent . className = "error" ;
el . catalogContent . textContent = err . message ;
}
}
}
async function loadStats ( ) {
async function loadStats ( ) {
@ -798,7 +1061,7 @@ async function connect() {
showConnectedUi ( true ) ;
showConnectedUi ( true ) ;
try {
try {
await Promise . all ( [ loadStats ( ) , loadAssets ( ) , loadOptions ( ) ] ) ;
await Promise . all ( [ loadStats ( ) , loadAssets ( ) , loadOptions ( ) , loadCatalog ( ) ] ) ;
} catch ( err ) {
} catch ( err ) {
showConnectedUi ( false ) ;
showConnectedUi ( false ) ;
showError ( err ) ;
showError ( err ) ;
@ -834,6 +1097,35 @@ el.filterStale.addEventListener("change", () => {
loadAssets ( ) . catch ( showError ) ;
loadAssets ( ) . catch ( showError ) ;
} ) ;
} ) ;
function bindFilterInputs ( ) {
const filterEls = [
el . filterOwner ,
el . filterModel ,
el . filterSeries ,
el . filterManufacturer ,
el . filterDeviceStatus ,
el . filterAssetFor ,
el . filterAssetOn ,
el . searchIn ,
] . filter ( Boolean ) ;
filterEls . forEach ( ( input ) => {
const evt = input . tagName === "SELECT" ? "change" : "input" ;
input . addEventListener ( evt , ( ) => {
clearTimeout ( state . searchTimer ) ;
state . searchTimer = setTimeout ( ( ) => {
state . page = 1 ;
loadAssets ( ) . catch ( showError ) ;
} , input . tagName === "SELECT" ? 0 : 350 ) ;
} ) ;
} ) ;
}
if ( el . clearFilters ) {
el . clearFilters . addEventListener ( "click" , resetFilters ) ;
}
bindFilterInputs ( ) ;
el . prevPage . addEventListener ( "click" , ( ) => {
el . prevPage . addEventListener ( "click" , ( ) => {
if ( state . page > 1 ) {
if ( state . page > 1 ) {
state . page -= 1 ;
state . page -= 1 ;
@ -850,6 +1142,72 @@ el.nextPage.addEventListener("click", () => {
el . closeDetail . addEventListener ( "click" , closeDetail ) ;
el . closeDetail . addEventListener ( "click" , closeDetail ) ;
if ( el . toggleCatalog && el . catalogPanel ) {
el . toggleCatalog . addEventListener ( "click" , ( ) => {
el . catalogPanel . classList . toggle ( "hidden" ) ;
if ( ! el . catalogPanel . classList . contains ( "hidden" ) ) {
loadCatalog ( ) . catch ( ( err ) => {
el . catalogContent . className = "error" ;
el . catalogContent . textContent = err . message ;
} ) ;
}
} ) ;
}
if ( el . catalogSync ) {
el . catalogSync . addEventListener ( "click" , async ( ) => {
el . catalogSync . disabled = true ;
try {
const result = await apiMutate ( "POST" , "/api/v1/device-models/sync-from-assets" , { } ) ;
alert (
` Sync selesai. \n ` +
` • ${ result . inserted } model baru ditambahkan \n ` +
` • ${ result . already _exists } sudah ada di catalog \n ` +
` • ${ result . pending _series } masih perlu diisi series \n ` +
` • ${ result . distinct _asset _models } kombinasi unik di assets `
) ;
await loadCatalog ( ) ;
} catch ( err ) {
alert ( err . message ) ;
} finally {
el . catalogSync . disabled = false ;
}
} ) ;
}
if ( el . catalogPendingOnly ) {
el . catalogPendingOnly . addEventListener ( "change" , ( ) => {
loadCatalog ( ) . catch ( showError ) ;
} ) ;
}
if ( el . catalogForm ) {
el . catalogForm . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const submitBtn = el . catalogForm . querySelector ( 'button[type="submit"]' ) ;
submitBtn . disabled = true ;
try {
await apiMutate ( "POST" , "/api/v1/device-models" , {
manufacturer : el . catManufacturer . value . trim ( ) ,
model : el . catModel . value . trim ( ) ,
series : el . catSeries . value . trim ( ) ,
source : "manual" ,
notes : el . catNotes . value . trim ( ) || null ,
} ) ;
el . catSeries . value = "" ;
el . catNotes . value = "" ;
await Promise . all ( [ loadCatalog ( ) , loadAssets ( ) ] ) ;
if ( state . selectedId ) {
openDetail ( state . selectedId ) ;
}
} catch ( err ) {
alert ( err . message ) ;
} finally {
submitBtn . disabled = false ;
}
} ) ;
}
loadSettings ( ) ;
loadSettings ( ) ;
if ( ! el . apiBase . value ) el . apiBase . value = defaultBaseUrl ( ) ;
if ( ! el . apiBase . value ) el . apiBase . value = defaultBaseUrl ( ) ;
if ( el . apiToken . value ) connect ( ) ;
if ( el . apiToken . value ) connect ( ) ;