149 "/static/dhcp/hosts.css", |
159 "/static/dhcp/hosts.css", |
150 ) |
160 ) |
151 JS = ( |
161 JS = ( |
152 #"/static/jquery/jquery.js" |
162 #"/static/jquery/jquery.js" |
153 ) |
163 ) |
154 |
164 |
155 HOST_ATTRS = { |
165 TABLE = Host |
156 'id': Host.id, |
166 TABLE_COLUMNS = ( |
157 'net': Host.gw, |
167 #column('id', "#", Host.id ), |
158 'ip': Host.ip, |
168 column('ip', "IP", Host.ip, ), |
159 'mac': Host.mac, |
169 column('mac', "MAC", Host.mac, Host.render_mac), |
160 'name': Host.name, |
170 column('name', "Hostname", Host.name, Host.render_name, rowfilter=False), |
161 'seen': Host.last_seen, |
171 column('gw', "Network", Host.gw, Host.network, rowfilter=False), |
162 'state': Host.state, |
172 column('seen', "Seen", Host.last_seen, Host.seen, rowfilter=False), |
163 'count': Host.count, |
173 column('state', "State", Host.count, rowtitle=Host.state_title, rowcss=Host.state_class, rowfilter=False), |
164 } |
174 ) |
165 |
175 |
166 HOST_SORT = Host.last_seen.desc() |
176 # attr -> column |
|
177 TABLE_ATTRS = dict((attr, column) for attr, title, column, sort, filter, colcss, rowhtml, rowfilter, rowtitle, rowcss in TABLE_COLUMNS) |
|
178 |
|
179 # default sort |
|
180 TABLE_SORT = Host.last_seen.desc() |
|
181 |
|
182 # items per page |
|
183 TABLE_PAGE = 10 |
|
184 |
|
185 # target for items |
|
186 TABLE_URL = None |
|
187 TABLE_ITEM_URL = None |
167 |
188 |
168 def query (self) : |
189 def query (self) : |
169 return self.db.query(Host) |
190 """ |
170 |
191 Database SELECT query. |
171 def sort (self, hosts, default=HOST_SORT) : |
192 """ |
|
193 |
|
194 return self.db.query(self.TABLE) |
|
195 |
|
196 def sort (self, query, default=TABLE_SORT) : |
|
197 """ |
|
198 Apply ?sort= from requset args to query. |
|
199 |
|
200 Return { attr: sort }, query |
|
201 """ |
|
202 |
172 sort = self.request.args.get('sort') |
203 sort = self.request.args.get('sort') |
173 |
204 |
174 if sort : |
205 if sort : |
175 name = sort.lstrip('+-') |
206 name = sort.lstrip('+-') |
176 else : |
207 else : |
177 name = None |
208 name = None |
178 |
209 |
179 if name : |
210 if name : |
180 order_by = self.HOST_ATTRS[name] |
211 order_by = self.TABLE_ATTRS[name] |
181 else : |
212 else : |
182 order_by = default |
213 order_by = default |
183 |
214 |
184 # prefix |
215 # prefix -> ordering |
185 if not sort : |
216 if not sort : |
186 pass |
217 pass |
187 elif sort.startswith('+') : |
218 elif sort.startswith('+') : |
188 order_by = order_by.asc() |
219 order_by = order_by.asc() |
189 elif sort.startswith('-') : |
220 elif sort.startswith('-') : |
190 order_by = order_by.desc() |
221 order_by = order_by.desc() |
191 else : |
222 else : |
192 pass |
223 pass |
193 |
224 |
|
225 # apply |
194 log.debug("sort: %s", order_by) |
226 log.debug("sort: %s", order_by) |
195 |
227 |
196 hosts = hosts.order_by(order_by) |
228 query = query.order_by(order_by) |
197 |
229 |
198 # k |
230 return sort, query |
199 return sort, hosts |
231 |
200 |
232 def filter_seen (self, value) : |
|
233 """ |
|
234 Return filter expression for given attr == value |
|
235 """ |
|
236 |
|
237 column = Host.last_seen |
|
238 |
|
239 if value.isdigit() : |
|
240 # specific date |
|
241 date = datetime.datetime.strptime(value, Host.DATE_FMT).date() |
|
242 |
|
243 return db.between(date.strftime(Host.DATE_FMT), |
|
244 db.func.strftime(Host.DATE_FMT, Host.first_seen), |
|
245 db.func.strftime(Host.DATE_FMT, Host.last_seen) |
|
246 ) |
|
247 else : |
|
248 # recent |
|
249 timedelta = parse_timedelta(value) |
|
250 |
|
251 return ((db.func.now() - Host.last_seen) < timedelta) |
|
252 |
|
253 # XXX: for sqlite, pgsql should handle this natively? |
|
254 # to seconds |
|
255 #timeout = timedelta.days * (24 * 60 * 60) + timedelta.seconds |
|
256 |
|
257 # WHERE strftime('%s', 'now') - strftime('%s', last_seen) < :timeout |
|
258 #filter = (db.func.strftime('%s', 'now') - db.func.strftime('%s', Host.last_seen) < timeout) |
|
259 |
|
260 def filter_ip (self, value) : |
|
261 column = Host.ip |
|
262 |
|
263 # column is IPv4 string literal format... |
|
264 if '/' in value : |
|
265 return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value))) |
|
266 else : |
|
267 return (db.func.inet(Host.ip) == db.func.inet(value)) |
|
268 |
|
269 def filter_mac (self, value) : |
|
270 return self.filter_attr('mac', Host.normalize_mac(value)) |
|
271 |
201 def filter_attr (self, attr, value) : |
272 def filter_attr (self, attr, value) : |
202 """ |
273 """ |
203 Return filter expression for given attr == value |
274 Return filter expression for given attr == value |
204 """ |
275 """ |
205 |
276 |
206 if attr == 'seen' : |
277 # preprocess |
207 column = Host.last_seen |
278 like = False |
208 |
279 |
209 if value.isdigit() : |
280 if value.endswith('*') : |
210 # specific date |
281 like = value.replace('*', '%') |
211 date = datetime.datetime.strptime(value, Host.DATE_FMT).date() |
282 |
212 |
283 # filter |
213 return db.between(date.strftime(Host.DATE_FMT), |
284 column = self.TABLE_ATTRS[attr] |
214 db.func.strftime(Host.DATE_FMT, Host.first_seen), |
285 |
215 db.func.strftime(Host.DATE_FMT, Host.last_seen) |
286 if like : |
216 ) |
287 return (column.like(like)) |
|
288 else : |
|
289 return (column == value) |
|
290 |
|
291 def _filter (self, attr, values) : |
|
292 """ |
|
293 Apply filters for given attr -> (value, expression) |
|
294 """ |
|
295 |
|
296 for value in values : |
|
297 value = value.strip() |
|
298 |
|
299 # ignore empty fields |
|
300 if not value : |
|
301 continue |
|
302 |
|
303 # lookup attr-specific filter |
|
304 filter = getattr(self, 'filter_{attr}'.format(attr=attr), None) |
|
305 |
|
306 if filter : |
|
307 filter = filter(value) |
217 else : |
308 else : |
218 # recent |
309 # use generic |
219 timedelta = parse_timedelta(value) |
310 filter = self.filter_attr(attr, value) |
220 |
311 |
221 return ((db.func.now() - Host.last_seen) < timedelta) |
312 log.debug("%s: %s: %s", attr, value, filter) |
222 |
313 |
223 # XXX: for sqlite, pgsql should handle this natively? |
314 yield value, filter |
224 # to seconds |
315 |
225 #timeout = timedelta.days * (24 * 60 * 60) + timedelta.seconds |
316 def filter (self, query) : |
226 |
|
227 # WHERE strftime('%s', 'now') - strftime('%s', last_seen) < :timeout |
|
228 #filter = (db.func.strftime('%s', 'now') - db.func.strftime('%s', Host.last_seen) < timeout) |
|
229 |
|
230 elif attr == 'ip' : |
|
231 column = Host.ip |
|
232 |
|
233 # column is IPv4 string literal format... |
|
234 if '/' in value : |
|
235 return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value))) |
|
236 else : |
|
237 return (db.func.inet(Host.ip) == db.func.inet(value)) |
|
238 |
|
239 else : |
|
240 # preprocess |
|
241 like = False |
|
242 |
|
243 if value.endswith('*') : |
|
244 like = value.replace('*', '%') |
|
245 |
|
246 elif attr == 'mac' : |
|
247 value = Host.normalize_mac(value) |
|
248 |
|
249 # filter |
|
250 column = self.HOST_ATTRS[attr] |
|
251 |
|
252 if like : |
|
253 return (column.like(like)) |
|
254 else : |
|
255 return (column == value) |
|
256 |
|
257 def filter (self, hosts) : |
|
258 """ |
317 """ |
259 Apply filters from request.args against given hosts. |
318 Apply filters from request.args against given hosts. |
260 |
319 |
261 Returns (filters, hosts). |
320 Returns (filters, hosts). |
262 """ |
321 """ |
263 |
322 |
264 # filter? |
323 # filter? |
265 filters = {} |
324 filters = {} |
266 |
325 |
267 for attr in self.HOST_ATTRS : |
326 for attr in self.TABLE_ATTRS : |
268 values = [value.strip() for value in self.request.args.getlist(attr) if value.strip()] |
327 # from request args |
|
328 values = self.request.args.getlist(attr) |
|
329 |
|
330 # lookup attr filters as expressions |
|
331 value_filters = list(self._filter(attr, values)) |
269 |
332 |
270 # ignore empty fields |
333 # ignore empty fields |
271 if not values : |
334 if not value_filters : |
272 continue |
335 continue |
273 |
336 |
274 # build query expression |
337 # filtering values, and filter expressions |
275 filter = db.or_(*[self.filter_attr(attr, value) for value in values]) |
338 values, expressions = zip(*value_filters) |
276 |
339 |
277 log.debug("filter %s: %s", attr, filter) |
340 # apply |
278 |
341 query = query.filter(db.or_(*expressions)) |
279 hosts = hosts.filter(filter) |
|
280 filters[attr] = values |
342 filters[attr] = values |
281 |
343 |
282 return filters, hosts |
344 return filters, query |
283 |
345 |
284 def filters_title (self) : |
346 def filters_title (self) : |
285 """ |
347 """ |
286 Return a string representing the applied filters. |
348 Return a string representing the applied filters. |
287 """ |
349 """ |
288 |
350 |
289 return ', '.join(value for values in self.filters.itervalues() for value in values) |
351 return ', '.join(value for values in self.filters.itervalues() for value in values) |
290 |
352 |
291 def render_hosts (self, hosts, title=None, filters=False, page=None, hilight=None) : |
353 def render_table (self, query, caption=None, sort=None, filters=None, page=None, hilight=None) : |
292 COLS = ( |
354 """ |
293 #title sort filter class |
355 Return <table> element. Wrapped in <form> if filters. |
294 ('IP', 'ip', 'ip', 'ip' ), |
356 |
295 ('MAC', 'mac', 'mac', 'mac' ), |
357 query - filter()'d sort()'d SELECT query() |
296 ('Hostname', 'name', False, False ), |
358 caption - optional <caption> |
297 ('Network', 'net', 'net', False ), |
359 sort - None for no sorting ui, sort-attr otherwise. |
298 ('Seen', 'seen', 'seen', 'seen' ), |
360 filters - None for no filtering ui, dict of filters otherwise. |
299 ('State', 'state', 'state', False ), |
361 page - display pagination for given page |
300 ) |
362 hilight - { attr: value } cells to hilight |
301 |
363 """ |
302 def url (**opts) : |
364 |
|
365 def url (filters=filters, sort=sort, **opts) : |
|
366 """ |
|
367 URL for table with given opts, keeping our sorting/filtering unless overriden. |
|
368 """ |
|
369 |
303 args = dict() |
370 args = dict() |
304 |
371 |
305 if filters : |
372 if filters : |
306 args.update(filters) |
373 args.update(filters) |
307 |
374 |
308 args.update(opts) |
375 if sort : |
309 |
376 args['sort'] = sort |
310 return self.url(**args) |
377 |
311 |
378 if opts : |
312 def sortlink (attr) : |
379 args.update(opts) |
313 if not self.sorts : |
380 |
|
381 return self.url(self.TABLE_URL, **args) |
|
382 |
|
383 def sorturl (attr, sort=sort) : |
|
384 """ |
|
385 URL for table sorted by given column, reversing direction if already sorting by given column. |
|
386 """ |
|
387 |
|
388 if not sort : |
314 sort = attr |
389 sort = attr |
315 elif self.sorts.lstrip('+-') != attr : |
390 elif sort.lstrip('+-') != attr : |
316 sort = attr |
391 sort = attr |
317 elif self.sorts.startswith('-') : |
392 elif sort.startswith('-') : |
318 sort = "+" + attr |
393 sort = "+" + attr |
319 else : |
394 else : |
320 sort = "-" + attr |
395 sort = "-" + attr |
321 |
396 |
322 return html.a(href=url(sort=sort)) |
397 return url(sort=sort) |
323 |
398 |
324 def paginate (page, count=None) : |
399 def itemurl (item) : |
325 """ |
400 """ |
326 Render pagination. |
401 URL for given item, by id. |
327 """ |
402 """ |
328 |
403 |
329 if count is not None : |
404 if self.TABLE_ITEM_URL : |
330 pages = int(math.ceil(count / self.PAGE)) # XXX: bad self.PAGE |
405 # separate page |
|
406 return self.url(self.TABLE_ITEM_URL, id=item.id) |
331 else : |
407 else : |
332 pages = None |
408 # to our table |
333 |
409 return url() + '#{id}'.format(id=item.id) |
334 if page > 0 : |
410 |
335 yield html.a(href=url(page=0))(html("«« First")) |
411 def render_filter (attr) : |
336 yield html.a(href=url(page=(page - 1)))(html("« Prev")) |
412 """ |
337 |
413 Render filter-input for column. |
338 yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=(pages or '???'))) |
414 """ |
339 |
415 |
340 yield html.a(href=url(page=(page + 1)))(html("» Next")) |
416 value = filters.get(attr) |
341 |
|
342 def render_filter (filter) : |
|
343 value = filters.get(filter) |
|
344 |
417 |
345 if value : |
418 if value : |
346 # XXX: multi-valued filters? |
419 # XXX: multi-valued filters? |
347 value = value[0] |
420 value = value[0] |
348 else : |
421 else : |
349 value = None |
422 value = None |
350 |
423 |
351 return html.input(type='text', name=filter, value=value) |
424 return html.input(type='text', name=attr, value=value) |
352 |
425 |
353 def render_cell (attr, value, cssclass=True, filter=None, htmlvalue=None) : |
426 def render_head () : |
354 if htmlvalue : |
427 """ |
355 cell = htmlvalue |
428 Yield header, filter rows for columns in table header. |
|
429 """ |
|
430 |
|
431 # id |
|
432 yield html.td('#'), html.td(html.input(type='submit', value=u'\u00BF')) |
|
433 |
|
434 for attr, title, column, sort, filter, colcss, rowhtml, rowfilter, rowtitle, rowcss in self.TABLE_COLUMNS : |
|
435 header = title |
|
436 |
|
437 if sort : |
|
438 header = html.a(href=sorturl(attr))(header) |
|
439 |
|
440 header = html.th(header) |
|
441 |
|
442 if filters is not None and filter : |
|
443 filter = render_filter(attr) |
|
444 else : |
|
445 filter = None |
|
446 |
|
447 if colcss is True : |
|
448 colcss = attr |
|
449 |
|
450 filter = html.td(class_=colcss)(filter) |
|
451 |
|
452 yield header, filter |
|
453 |
|
454 def render_cell (attr, value, rowhtml=None, colcss=True, filter=None, rowtitle=None, rowcss=None, hilight=hilight) : |
|
455 """ |
|
456 Render a single cell. |
|
457 |
|
458 colcss - css class for column; True -> attr |
|
459 filter - render filter link for value? |
|
460 htmlvalue - rendered value? |
|
461 title - mouseover title for cell |
|
462 rowcss - css class for row |
|
463 """ |
|
464 |
|
465 if not rowhtml : |
|
466 rowhtml = value |
|
467 |
|
468 if filter : |
|
469 cell = html.a(href=url(filters=None, **{attr: value}))(rowhtml) |
356 else : |
470 else : |
357 cell = value |
471 cell = rowhtml |
358 |
472 |
359 if filter : |
473 if colcss is True : |
360 cell = html.a(href=self.url(ListHandler, **{attr: value}))(cell) |
474 colcss = attr |
361 |
475 |
362 if cssclass is True : |
476 if hilight : |
363 cssclass = attr |
477 hilight = attr in hilight and value in hilight[attr] |
364 |
478 |
365 css = (cssclass, 'hilight' if (hilight and attr in hilight and value in hilight[attr]) else None) |
479 css = (colcss, rowcss, 'hilight' if hilight else None) |
366 css = ' '.join(cls for cls in css if cls) |
480 css = ' '.join(cls for cls in css if cls) |
367 |
481 |
368 return html.td(class_=css)(cell) |
482 return html.td(class_=css, title=rowtitle)(cell) |
369 |
483 |
|
484 def render_row (item) : |
|
485 """ |
|
486 Yield columns for row. |
|
487 """ |
|
488 |
|
489 for attr, title, column, sort, filter, colcss, rowhtml, rowfilter, rowtitle, rowcss in self.TABLE_COLUMNS : |
|
490 # XXX: this is sometimes broken, figure out how to index by column |
|
491 value = getattr(item, attr) |
|
492 |
|
493 if rowhtml : |
|
494 rowhtml = rowhtml(item) |
|
495 else : |
|
496 rowhtml = value |
|
497 |
|
498 if rowtitle : |
|
499 rowtitle = rowtitle(item) |
|
500 else : |
|
501 rowtitle = None |
|
502 |
|
503 if rowcss : |
|
504 rowcss = rowcss(item) |
|
505 else : |
|
506 rowcss = None |
|
507 |
|
508 yield render_cell(attr, value, |
|
509 rowhtml = rowhtml, |
|
510 colcss = colcss, |
|
511 filter = value if rowfilter else None, |
|
512 rowtitle = rowtitle, |
|
513 rowcss = rowcss, |
|
514 ) |
|
515 |
|
516 def render_body (rows) : |
|
517 """ |
|
518 Yield rows. |
|
519 """ |
|
520 |
|
521 for i, item in enumerate(rows) : |
|
522 yield html.tr(class_=('alternate' if i % 2 else None), id=item.id)( |
|
523 html.th( |
|
524 html.a(href=itemurl(item))("#") |
|
525 ), |
|
526 |
|
527 render_row(item) |
|
528 ) |
|
529 |
|
530 def render_pagination (page, count=None) : |
|
531 """ |
|
532 Render pagination links. |
|
533 """ |
|
534 |
|
535 if count is not None : |
|
536 pages = int(math.ceil(count / self.TABLE_PAGE)) |
|
537 else : |
|
538 pages = None |
|
539 |
|
540 if page > 0 : |
|
541 yield html.a(href=url(page=0))(html("«« First")) |
|
542 yield html.a(href=url(page=(page - 1)))(html("« Prev")) |
|
543 |
|
544 yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=(pages or '???'))) |
|
545 |
|
546 yield html.a(href=url(page=(page + 1)))(html("» Next")) |
|
547 |
|
548 |
|
549 def render_foot () : |
|
550 # XXX: does separate SELECT count() |
|
551 count = query.count() |
|
552 |
|
553 if page : |
|
554 return render_pagination(page, count) |
|
555 else : |
|
556 return "{count} hosts".format(count=count) |
|
557 |
|
558 # columns for the two header rows |
|
559 headers, filtering = zip(*list(render_head())) |
|
560 |
|
561 # render table |
370 table = html.table( |
562 table = html.table( |
371 html.caption(title) if title else None, |
563 html.caption(caption) if caption else None, |
372 html.thead( |
564 html.thead( |
373 html.tr( |
565 html.tr(headers), |
374 html.th('#'), |
566 # filters? |
375 ( |
567 html.tr(class_='filter')(filtering) if filters is not None else None, |
376 html.th( |
|
377 sortlink(sort)(title) if sort else (title) |
|
378 ) for title, sort, filter, class_ in COLS |
|
379 ) |
|
380 ), |
|
381 html.tr(class_='filter')( |
|
382 html.td( |
|
383 html.input(type='submit', value=u'\u00BF'), |
|
384 ), |
|
385 ( |
|
386 html.td(class_=class_)( |
|
387 render_filter(filter) if filter else None |
|
388 ) for title, sort, filter, class_ in COLS |
|
389 ) |
|
390 ) if filters is not False else None |
|
391 ), |
568 ), |
392 html.tbody( |
569 html.tbody( |
393 html.tr(class_=('alternate' if i % 2 else None), id=host.id)( |
570 render_body(query) |
394 html.th( |
|
395 html.a(href=self.url(ItemHandler, id=host.id))( |
|
396 '#' #host.id |
|
397 ) |
|
398 ), |
|
399 |
|
400 render_cell('ip', host.ip, filter=True), |
|
401 render_cell('mac', host.mac, filter=True, htmlvalue=host.render_mac()), |
|
402 render_cell('name', host.name, htmlvalue=host.render_name()), |
|
403 render_cell('gw', host.gw), |
|
404 |
|
405 render_cell('seen', host.seen()), |
|
406 html.td(class_=host.state_class(), title=host.state_title())(host.state), |
|
407 ) for i, host in enumerate(hosts) |
|
408 ), |
571 ), |
409 html.tfoot( |
572 html.tfoot( |
410 html.tr( |
573 html.tr( |
411 html.td(colspan=(1 + len(COLS)))( |
574 html.td(colspan=(1 + len(self.TABLE_COLUMNS)))( |
412 paginate(page) if page is not None else ( |
575 render_foot() |
413 # XXX: does separate SELECT count() |
|
414 "{count} hosts".format(count=hosts.count()) |
|
415 ) |
|
416 ) |
576 ) |
417 ) |
577 ) |
418 ) |
578 ) |
419 ) |
579 ) |
420 |
580 |
421 if filters is False : |
581 # filters form? |
|
582 if filters is None : |
422 return table |
583 return table |
423 else : |
584 else : |
424 return html.form(method='get', action=self.url())( |
585 return html.form(method='get', action=url(filters=None, sort=None))( |
425 html.input(type='hidden', name='sort', value=self.sorts), |
586 html.input(type='hidden', name='sort', value=sort), |
426 table, |
587 table, |
427 ) |
588 ) |
428 |
589 |
429 class ItemHandler (BaseHandler) : |
590 class ItemHandler (BaseHandler) : |
430 """ |
591 """ |
431 A specific DHCP host, along with a list of related hosts. |
592 A specific DHCP host, along with a list of related hosts. |
432 """ |
593 """ |
433 |
594 |
434 def process (self, id) : |
595 def process (self, id) : |
435 self.hosts = self.query() |
596 self.hosts = self.query() |
436 self.host = self.hosts.get(id) |
597 self.host = self.hosts.get(id) |
437 |
598 |
438 if not self.host : |
599 if not self.host : |