Warranty phase 2: real Dell provider + settings UI + PC hero badge

- DellProvider: real Dell TechDirect lookup. OAuth2 via HTTP Basic auth,
  asset-entitlements under /PROD/sbil/eapi/v5 (the device.warranty path 404s
  for this account), map latest dated entitlement to service level + dates.
  Cache the token process-wide; Dell rate-limits the token endpoint and a
  fresh request per refresh trips a 401 cooldown. Verified against live Dell.
- Lenovo/HP stay config-shaped stubs.
- Settings: warranty_dell_* keys (category integrations); Dell Warranty Lookup
  block in System Settings > Integrations (enable + client id/secret masked +
  optional token/API URL overrides).
- WarrantyPanel takes optional pre-fetched items; PCDetail fetches once and
  feeds both the panel and a new hero warranty-status/end-date badge.
- tools/mock_dell.py for offline testing of the provider flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 16:05:26 -04:00
parent 78a0ee8d83
commit 7e9650a62b
8 changed files with 370 additions and 30 deletions

View File

@@ -33,6 +33,9 @@ const props = defineProps({
assetid: { type: [Number, String], default: null },
// When true, render the card even with no warranties (shows an "Add one" link).
showEmpty: { type: Boolean, default: false },
// Optional pre-fetched warranties. When provided, the panel uses them instead
// of fetching (lets a parent share one fetch with e.g. a hero badge).
items: { type: Array, default: null },
})
const warranties = ref([])
@@ -47,6 +50,8 @@ function formatDate(d) {
}
async function load() {
// Parent-supplied data wins - skip the fetch.
if (props.items !== null) { warranties.value = props.items; return }
if (!props.assetid) { warranties.value = []; return }
try {
const response = await warrantyApi.forAsset(props.assetid)
@@ -59,6 +64,7 @@ async function load() {
onMounted(load)
watch(() => props.assetid, load)
watch(() => props.items, load)
</script>
<style scoped>

View File

@@ -23,6 +23,10 @@
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertypename">
@@ -223,7 +227,7 @@
<CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="computer.assetid" />
<WarrantyPanel :assetid="computer.assetid" :items="warranties" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
@@ -250,7 +254,7 @@
import { ref, onMounted, computed } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute } from 'vue-router'
import { computersApi, applicationsApi, assetsApi } from '../../api'
import { computersApi, applicationsApi, assetsApi, warrantyApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
@@ -263,6 +267,21 @@ const loading = ref(true)
const computer = ref(null)
const relationships = ref({ incoming: [], outgoing: [] })
const installedApps = ref([])
const warranties = ref([])
// Worst-case warranty for the hero badge: expired > expiring > active > unknown.
const heroWarranty = computed(() => {
if (!warranties.value.length) return null
const rank = { expired: 0, expiring: 1, active: 2, unknown: 3 }
const worst = [...warranties.value].sort((a, b) => (rank[a.status] ?? 9) - (rank[b.status] ?? 9))[0]
const labels = { active: 'Under Warranty', expiring: 'Warranty Expiring', expired: 'Warranty Expired', unknown: 'Warranty' }
return { ...worst, label: labels[worst.status] || 'Warranty' }
})
function formatWarrantyDate(d) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString()
}
const controlledEquipment = computed(() => {
// For computers, find related equipment in any "Controls" relationship
@@ -315,6 +334,16 @@ onMounted(async () => {
} catch (appError) {
console.log('No installed apps data:', appError.message)
}
// Load warranties (one fetch; feeds both the hero badge and the panel)
if (computer.value?.assetid) {
try {
const warrantyResponse = await warrantyApi.forAsset(computer.value.assetid)
warranties.value = warrantyResponse.data.data || []
} catch (warrantyError) {
console.log('No warranty data:', warrantyError.message)
}
}
} catch (error) {
console.error('Error loading computer:', error)
} finally {

View File

@@ -77,6 +77,86 @@
<span>{{ zabbixMessage }}</span>
</div>
</div>
<div class="setting-group">
<h3>Dell Warranty Lookup</h3>
<p class="setting-description">
Look up Dell coverage by service tag via the Dell TechDirect Warranty API.
When enabled, the Refresh button on a Dell warranty pulls the current service
level and end date. Requires a Dell TechDirect API client id and secret.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Dell Warranty Lookup</span>
<button
class="toggle-btn"
:class="{ active: settings.warranty_dell_enabled }"
@click="toggleSetting('warranty_dell_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Client ID</span>
<input
type="text"
v-model="settings.warranty_dell_clientid"
placeholder="Dell TechDirect client id"
@blur="saveSetting('warranty_dell_clientid', settings.warranty_dell_clientid)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Client Secret</span>
<input
type="password"
v-model="settings.warranty_dell_clientsecret"
placeholder="Enter client secret"
@blur="saveSetting('warranty_dell_clientsecret', settings.warranty_dell_clientsecret)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Token URL <small>(blank = Dell default)</small></span>
<input
type="url"
v-model="settings.warranty_dell_tokenurl"
placeholder="https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
@blur="saveSetting('warranty_dell_tokenurl', settings.warranty_dell_tokenurl)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>API URL <small>(blank = Dell default)</small></span>
<input
type="url"
v-model="settings.warranty_dell_apiurl"
placeholder="https://apigtwb2c.us.dell.com/PROD/sbfxapp/device.warranty/v5/asset-entitlements"
@blur="saveSetting('warranty_dell_apiurl', settings.warranty_dell_apiurl)"
:disabled="saving"
>
</label>
</div>
<div class="status-indicator" v-if="settings.warranty_dell_enabled">
<span class="status-dot" :class="dellStatus"></span>
<span>{{ dellMessage }}</span>
</div>
</div>
</div>
<!-- Email Section -->
@@ -625,6 +705,11 @@ const settings = reactive({
zabbix_enabled: false,
zabbix_url: '',
zabbix_token: '',
warranty_dell_enabled: false,
warranty_dell_clientid: '',
warranty_dell_clientsecret: '',
warranty_dell_tokenurl: '',
warranty_dell_apiurl: '',
// Email
smtp_enabled: false,
smtp_host: '',
@@ -720,6 +805,20 @@ const zabbixMessage = computed(() => {
return 'Configured (connectivity checked on first use)'
})
// Dell warranty status
const dellStatus = computed(() => {
if (!settings.warranty_dell_enabled) return 'inactive'
if (!settings.warranty_dell_clientid || !settings.warranty_dell_clientsecret) return 'warning'
return 'pending'
})
const dellMessage = computed(() => {
if (!settings.warranty_dell_enabled) return 'Disabled'
if (!settings.warranty_dell_clientid) return 'Client id not configured'
if (!settings.warranty_dell_clientsecret) return 'Client secret not configured'
return 'Configured (used when you refresh a Dell warranty)'
})
// SMTP status
const smtpStatus = computed(() => {
if (!settings.smtp_enabled) return 'inactive'